792 lines
25 KiB
Python
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
|