Files
2026-07-13 12:08:54 +08:00

792 lines
25 KiB
Python

#!/usr/bin/env python3
"""
ABOUTME: Shared token estimation utilities for audit scripts
ABOUTME: XML sanitization helpers for document processing
"""
import json
import os
import re
try:
from google import genai
from google.genai import types
HAS_GEMINI = True
except ImportError: # pragma: no cover - optional dependency
genai = None
types = None
HAS_GEMINI = False
try:
import openai
HAS_OPENAI = True
except ImportError: # pragma: no cover - optional dependency
openai = None
HAS_OPENAI = False
def estimate_tokens(text: str) -> int:
"""
Estimate token count for LLM context management.
Uses a weighted formula based on character types:
- Chinese characters: ~0.75 tokens per character (subword tokenization)
- JSON structural characters (brackets, quotes, commas): ~1 tokens per character
- Other characters (English, numbers, symbols): ~0.4 tokens per character (~3 chars/token)
Includes 5% buffer and safety offset for special formatting and system prompt overhead.
Args:
text: Input text to estimate tokens for
Returns:
int: Estimated token count
"""
if not text:
return 0
chinese_count = len(re.findall(r"[\u4e00-\u9fa5]", text))
json_chars_count = len(re.findall(r'[\[\]",{}]', text))
other_count = len(text) - chinese_count - json_chars_count
base_estimate = (
(chinese_count * 0.75) + (json_chars_count * 1) + (other_count * 0.4)
)
final_tokens = int(base_estimate * 1.05) + 2
return final_tokens
def sanitize_xml_string(text: str) -> str:
"""
Remove control characters that are illegal in XML 1.0.
XML 1.0 allows: #x9 (tab), #xA (LF), #xD (CR), and #x20-#xD7FF, #xE000-#xFFFD, #x10000-#x10FFFF
This function removes all other control characters (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F).
Args:
text: Text that may contain control characters
Returns:
Sanitized text safe for XML. Returns input unchanged if not a non-empty string.
"""
if not text or not isinstance(text, str):
return text
# Build a translation table to remove illegal control characters
# Keep: \t (0x09), \n (0x0A), \r (0x0D)
# Remove: 0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F
illegal_chars = "".join(chr(c) for c in range(0x20) if c not in (0x09, 0x0A, 0x0D))
return text.translate(str.maketrans("", "", illegal_chars))
def is_vertex_ai_mode() -> bool:
"""
Check if Vertex AI mode is enabled via environment variable.
Returns:
True if GOOGLE_GENAI_USE_VERTEXAI is set to 'true', False otherwise
"""
return os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "").lower() == "true"
def create_gemini_client(use_async: bool = False):
"""
Create Gemini client for AI Studio or Vertex AI.
Supports two modes:
- AI Studio (default): Uses GOOGLE_API_KEY for authentication
- Vertex AI: Uses ADC (GOOGLE_APPLICATION_CREDENTIALS or gcloud auth)
Environment variables for Vertex AI mode:
- GOOGLE_GENAI_USE_VERTEXAI: Set to 'true' to enable Vertex AI mode
- GOOGLE_CLOUD_PROJECT: Required GCP project ID
- GOOGLE_CLOUD_LOCATION: Optional region (default: us-central1)
- GOOGLE_VERTEX_BASE_URL: Optional custom API endpoint (for API gateway proxies)
- GOOGLE_APPLICATION_CREDENTIALS: Path to service account JSON (or use gcloud auth)
Args:
use_async: If True, return the async client (.aio), otherwise return sync client
Returns:
Gemini client instance (sync or async based on use_async parameter)
Raises:
ValueError: If required environment variables are not set
"""
use_vertex = is_vertex_ai_mode()
if use_vertex:
# Vertex AI mode - uses ADC (GOOGLE_APPLICATION_CREDENTIALS or gcloud auth)
project = os.getenv("GOOGLE_CLOUD_PROJECT")
location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
base_url = os.getenv("GOOGLE_VERTEX_BASE_URL")
if not project:
raise ValueError(
"GOOGLE_CLOUD_PROJECT is required for Vertex AI mode. "
"Set GOOGLE_GENAI_USE_VERTEXAI=false to use AI Studio mode instead."
)
# Build http_options only if custom base_url is specified
http_options = None
if base_url:
http_options = {"base_url": base_url}
# Note: ADC handles authentication automatically
# via GOOGLE_APPLICATION_CREDENTIALS env var or gcloud auth
client = genai.Client(
vertexai=True, project=project, location=location, http_options=http_options
)
else:
# AI Studio mode - requires API key
api_key = os.getenv("GOOGLE_API_KEY")
if not api_key:
raise ValueError(
"GOOGLE_API_KEY is required for AI Studio mode. "
"Set GOOGLE_GENAI_USE_VERTEXAI=true and configure GCP credentials for Vertex AI mode."
)
client = genai.Client(api_key=api_key)
# Return async or sync client based on parameter
return client.aio if use_async else client
def get_gemini_provider_name() -> str:
"""
Get the Gemini provider name based on current mode.
Returns:
Provider name string for display purposes
"""
if is_vertex_ai_mode():
project = os.getenv("GOOGLE_CLOUD_PROJECT", "unknown")
location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
return f"Google Gemini (Vertex AI: {project}/{location})"
return "Google Gemini (AI Studio)"
def create_openai_client(use_async: bool = True):
"""
Create OpenAI client with optional custom base URL.
Environment variables:
- OPENAI_API_KEY: Required API key
- OPENAI_BASE_URL: Optional custom API endpoint (for proxies, Azure, etc.)
Args:
use_async: If True, return AsyncOpenAI, otherwise return OpenAI
Returns:
OpenAI client instance (async or sync based on use_async parameter)
Raises:
ValueError: If OPENAI_API_KEY is not set
"""
if not HAS_OPENAI:
raise ValueError("openai library is not installed.")
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY is required for OpenAI mode.")
base_url = os.getenv("OPENAI_BASE_URL")
if use_async:
return openai.AsyncOpenAI(base_url=base_url)
return openai.OpenAI(base_url=base_url)
def get_openai_provider_name() -> str:
"""
Get the OpenAI provider name, including custom endpoint if configured.
Returns:
Provider name string for display purposes
"""
base_url = os.getenv("OPENAI_BASE_URL")
if base_url:
return f"OpenAI (Custom: {base_url})"
return "OpenAI"
def is_openai_reasoning_model(model_name: str) -> bool:
"""
Check if the OpenAI model supports reasoning_effort parameter.
Models that support reasoning_effort:
- o-series: o1, o3, o4 and their variants (o1-mini, o1-2024-12-17, etc.)
- gpt-5 series: gpt-5, gpt-5.2, gpt-5-turbo, etc.
Non-reasoning models like gpt-4.1, gpt-4o, etc. will reject this parameter.
Handles proxy/router prefixes like "openai/o1-mini" or "openrouter/gpt-5.2".
Args:
model_name: The OpenAI model name (may include path prefix)
Returns:
True if the model supports reasoning_effort, False otherwise
"""
model_lower = model_name.lower()
# Handle proxy/router prefixes like "openai/o1-mini", "openrouter/gpt-5.2"
# Extract the base model name after the last "/"
if "/" in model_lower:
model_lower = model_lower.rsplit("/", 1)[-1]
# Match o-series and gpt-5 series
return model_lower.startswith(("o1", "o3", "o4", "gpt-5"))
def is_openai_retryable(error: Exception) -> bool:
"""
Determine if an OpenAI error should be retried.
Non-retryable errors:
- AuthenticationError (401): Invalid API key
- PermissionDeniedError (403): No access to resource
- BadRequestError (400): Invalid request format
- NotFoundError (404): Model or resource not found
Retryable errors:
- RateLimitError (429): Rate limit exceeded
- APIConnectionError: Network issues
- InternalServerError (500): Server errors
- APIStatusError with 502, 503, 504: Gateway/service errors
Args:
error: The exception from OpenAI API call
Returns:
True if the error should be retried, False otherwise
"""
if not HAS_OPENAI:
return True
# Authentication error - invalid API key (401)
if isinstance(error, openai.AuthenticationError):
return False
# Permission denied - no access to resource (403)
if isinstance(error, openai.PermissionDeniedError):
return False
# Bad request - invalid request format (400)
if isinstance(error, openai.BadRequestError):
return False
# Not found - model or resource doesn't exist (404)
if isinstance(error, openai.NotFoundError):
return False
# Rate limit exceeded - should retry with backoff (429)
if isinstance(error, openai.RateLimitError):
return True
# API connection error - network issues, should retry
if isinstance(error, openai.APIConnectionError):
return True
# Internal server error - should retry (500)
if isinstance(error, openai.InternalServerError):
return True
# For other APIStatusError, check HTTP status code
if isinstance(error, openai.APIStatusError):
# Retryable server-side errors
return error.status_code in (429, 500, 502, 503, 504)
# For unknown errors, default to retry (network issues, timeouts, etc.)
return True
def is_gemini_retryable(error: Exception) -> bool:
"""
Determine if a Gemini error should be retried.
Uses string matching on error messages since google-genai may not have
well-defined exception types for all error cases.
Non-retryable errors:
- API key errors
- Authentication/permission errors
- Invalid request errors
- Model not found errors
- Billing/quota permanently exceeded
Retryable errors:
- Rate limit (429)
- Server errors (500, 502, 503, 504)
- Timeout/connection errors
Args:
error: The exception from Gemini API call
Returns:
True if the error should be retried, False otherwise
"""
error_str = str(error).lower()
# API key / authentication errors - do not retry
if "api_key" in error_str or "api key" in error_str:
return False
if "authentication" in error_str or "authenticate" in error_str:
return False
if "invalid_api_key" in error_str or "invalid api key" in error_str:
return False
# Permission / forbidden errors - do not retry
if "permission" in error_str and "denied" in error_str:
return False
if "forbidden" in error_str or "403" in error_str:
return False
# Invalid request errors - do not retry
if "invalid" in error_str and ("request" in error_str or "argument" in error_str):
return False
if "400" in error_str and "bad request" in error_str:
return False
# Model not found - do not retry
if "model" in error_str and ("not found" in error_str or "not exist" in error_str):
return False
if "404" in error_str:
return False
# Billing / permanent quota errors - do not retry
if "billing" in error_str:
return False
if "quota" in error_str and ("exceeded" in error_str or "exhausted" in error_str):
# Check if it mentions billing which indicates permanent quota issue
if "billing" in error_str or "payment" in error_str:
return False
# Temporary quota (rate limit) - should retry
return True
# Rate limit errors - should retry (429)
if "rate" in error_str and "limit" in error_str:
return True
if "429" in error_str or "resource_exhausted" in error_str:
return True
# Server errors - should retry (500, 502, 503, 504)
if any(code in error_str for code in ["500", "502", "503", "504"]):
return True
if "internal" in error_str and ("error" in error_str or "server" in error_str):
return True
if "service" in error_str and "unavailable" in error_str:
return True
if "gateway" in error_str:
return True
# Timeout / connection errors - should retry
if "timeout" in error_str or "timed out" in error_str:
return True
if "connection" in error_str:
return True
if "network" in error_str:
return True
# Unknown errors - default to retry with limited attempts
return True
# JSON Schema for LLM structured output
AUDIT_RESULT_SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"is_violation": {
"type": "boolean",
"description": "Whether any violations were found",
},
"violations": {
"type": "array",
"description": "List of violations found",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"rule_id": {
"type": "string",
"description": "ID of the violated rule (e.g., R001)",
},
"violation_text": {
"type": "string",
"description": "The problematic text directly verbatim quote from the source content, and not span multiple cells",
},
"violation_reason": {
"type": "string",
"description": "Explanation of why this violates the rule",
},
"fix_action": {
"type": "string",
"enum": ["replace", "manual"],
"description": "Action type: replace substitutes text (including deletion-via-replace), manual requires human review",
},
"revised_text": {
"type": "string",
"description": "For replace: complete replacement text (including deletion-via-replace). For manual: additional guidance for human reviewer",
},
},
"required": [
"rule_id",
"violation_text",
"violation_reason",
"fix_action",
"revised_text",
],
},
},
},
"required": ["is_violation", "violations"],
}
# JSON Schema for global extraction output
GLOBAL_EXTRACT_SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"results": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"rule_id": {"type": "string"},
"extracted_results": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"entity": {"type": "string"},
"fields": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"name": {"type": "string"},
"value": {"type": "string"},
"evidence": {"type": "string"},
},
"required": ["name", "value", "evidence"],
},
},
},
"required": ["entity", "fields"],
},
},
},
"required": ["rule_id", "extracted_results"],
},
}
},
"required": ["results"],
}
# JSON Schema for global verification output
GLOBAL_VERIFY_SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"violations": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"rule_id": {"type": "string"},
"uuid": {"type": "string"},
"uuid_end": {"type": "string"},
"violation_text": {"type": "string"},
"violation_reason": {"type": "string"},
"fix_action": {"type": "string", "enum": ["replace", "manual"]},
"revised_text": {"type": "string"},
},
"required": [
"rule_id",
"uuid",
"uuid_end",
"violation_text",
"violation_reason",
"fix_action",
"revised_text",
],
},
}
},
"required": ["violations"],
}
async def global_extract_gemini_async(
user_prompt: str,
system_prompt: str,
model_name: str,
client,
thinking_level: str = None,
thinking_budget: int = None,
) -> dict:
thinking_config = None
if thinking_level and thinking_level.upper() in (
"MINIMAL",
"LOW",
"MEDIUM",
"HIGH",
):
level_map = {
"MINIMAL": types.ThinkingLevel.MINIMAL,
"LOW": types.ThinkingLevel.LOW,
"MEDIUM": types.ThinkingLevel.MEDIUM,
"HIGH": types.ThinkingLevel.HIGH,
}
thinking_config = types.ThinkingConfig(
thinking_level=level_map[thinking_level.upper()]
)
elif thinking_budget is not None:
thinking_config = types.ThinkingConfig(thinking_budget=int(thinking_budget))
config_params = {
"system_instruction": system_prompt,
"response_mime_type": "application/json",
"response_schema": GLOBAL_EXTRACT_SCHEMA,
}
if thinking_config:
config_params["thinking_config"] = thinking_config
response = await client.models.generate_content(
model=model_name,
contents=user_prompt,
config=types.GenerateContentConfig(**config_params),
)
return json.loads(response.text)
async def global_extract_openai_async(
user_prompt: str,
system_prompt: str,
model_name: str,
client,
reasoning_effort: str = None,
) -> dict:
request_params = {
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "global_extract",
"strict": True,
"schema": GLOBAL_EXTRACT_SCHEMA,
},
},
}
if (
reasoning_effort
and reasoning_effort.lower() in ("low", "medium", "high")
and is_openai_reasoning_model(model_name)
):
request_params["reasoning_effort"] = reasoning_effort.lower()
response = await client.chat.completions.create(**request_params)
return json.loads(response.choices[0].message.content)
async def global_verify_gemini_async(
user_prompt: str,
system_prompt: str,
model_name: str,
client,
thinking_level: str = None,
thinking_budget: int = None,
) -> dict:
thinking_config = None
if thinking_level and thinking_level.upper() in (
"MINIMAL",
"LOW",
"MEDIUM",
"HIGH",
):
level_map = {
"MINIMAL": types.ThinkingLevel.MINIMAL,
"LOW": types.ThinkingLevel.LOW,
"MEDIUM": types.ThinkingLevel.MEDIUM,
"HIGH": types.ThinkingLevel.HIGH,
}
thinking_config = types.ThinkingConfig(
thinking_level=level_map[thinking_level.upper()]
)
elif thinking_budget is not None:
thinking_config = types.ThinkingConfig(thinking_budget=int(thinking_budget))
config_params = {
"system_instruction": system_prompt,
"response_mime_type": "application/json",
"response_schema": GLOBAL_VERIFY_SCHEMA,
}
if thinking_config:
config_params["thinking_config"] = thinking_config
response = await client.models.generate_content(
model=model_name,
contents=user_prompt,
config=types.GenerateContentConfig(**config_params),
)
return json.loads(response.text)
async def global_verify_openai_async(
user_prompt: str,
system_prompt: str,
model_name: str,
client,
reasoning_effort: str = None,
) -> dict:
request_params = {
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "global_verify",
"strict": True,
"schema": GLOBAL_VERIFY_SCHEMA,
},
},
}
if (
reasoning_effort
and reasoning_effort.lower() in ("low", "medium", "high")
and is_openai_reasoning_model(model_name)
):
request_params["reasoning_effort"] = reasoning_effort.lower()
response = await client.chat.completions.create(**request_params)
return json.loads(response.choices[0].message.content)
async def audit_block_gemini_async(
user_prompt: str,
system_prompt: str,
model_name: str,
client,
thinking_level: str = None,
thinking_budget: int = None,
) -> dict:
"""
Audit a text block using Google Gemini with strict JSON mode (async version).
Args:
user_prompt: User prompt to audit
system_prompt: Cached system prompt with rules and instructions
model_name: Gemini model to use
client: Gemini async client instance (client.aio)
thinking_level: Thinking level for Gemini 3 models (MINIMAL, LOW, MEDIUM, HIGH)
thinking_budget: Thinking token budget for Gemini 2.5 models (integer)
Returns:
Audit result dictionary
"""
# Build thinking config based on model and parameters
thinking_config = None
if thinking_level and thinking_level.upper() in (
"MINIMAL",
"LOW",
"MEDIUM",
"HIGH",
):
# For Gemini 3 models
level_map = {
"MINIMAL": types.ThinkingLevel.MINIMAL,
"LOW": types.ThinkingLevel.LOW,
"MEDIUM": types.ThinkingLevel.MEDIUM,
"HIGH": types.ThinkingLevel.HIGH,
}
thinking_config = types.ThinkingConfig(
thinking_level=level_map[thinking_level.upper()]
)
elif thinking_budget is not None:
# For Gemini 2.5 models
thinking_config = types.ThinkingConfig(thinking_budget=int(thinking_budget))
config_params = {
"system_instruction": system_prompt,
"response_mime_type": "application/json",
"response_schema": AUDIT_RESULT_SCHEMA,
}
# Only add thinking_config if it's configured
if thinking_config:
config_params["thinking_config"] = thinking_config
response = await client.models.generate_content(
model=model_name,
contents=user_prompt,
config=types.GenerateContentConfig(**config_params),
)
# With structured output, response is guaranteed to be valid JSON
result = json.loads(response.text)
return result
async def audit_block_openai_async(
user_prompt: str,
system_prompt: str,
model_name: str,
client,
reasoning_effort: str = None,
) -> dict:
"""
Audit a text block using OpenAI with strict JSON mode (async version).
Args:
user_prompt: User prompt to audit
system_prompt: Cached system prompt with rules and instructions
model_name: OpenAI model to use
client: AsyncOpenAI client instance
reasoning_effort: Reasoning effort for o-series models (low, medium, high)
Returns:
Audit result dictionary
"""
request_params = {
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "audit_result",
"strict": True,
"schema": AUDIT_RESULT_SCHEMA,
},
},
}
# Add reasoning_effort only for o-series models that support it
if (
reasoning_effort
and reasoning_effort.lower() in ("low", "medium", "high")
and is_openai_reasoning_model(model_name)
):
request_params["reasoning_effort"] = reasoning_effort.lower()
response = await client.chat.completions.create(**request_params)
# With structured output, response is guaranteed to be valid JSON
result = json.loads(response.choices[0].message.content)
return result