Files
AI/참고/guardrails-main/guardrails/utils/parsing_utils.py

433 lines
15 KiB
Python
Raw Normal View History

2026-05-12 19:40:31 +09:00
import json
from guardrails.types.simple import SimpleTypes
import jsonref
import regex
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union, cast
from guardrails.actions.reask import NonParseableReAsk
from guardrails.classes.output_type import OutputTypes
from guardrails_ai.types import FailResult
from guardrails.schema.parser import get_all_paths
from guardrails.utils.safe_get import safe_get
### String to Dictionary Parsing ###
def has_code_block(
string_value: str, code_type: str = ""
) -> Tuple[bool, Optional[int], Optional[int]]:
"""Checks if a string contains a code block denoted by leading and trailing
tripple ticks (```) with an optional code type for the opening tag.
Args::
string_value (str): The string to check.
code_type (str, optional): The specific code type to check for. Defaults to empty string.
Returns
bool: Whether or not the string contains the specified type of code block.
int: The starting index of the code block.
int: The ending index of the code block.
""" # noqa
block_border = "```"
block_start_border = f"{block_border}{code_type}"
block_start_index = string_value.find(block_start_border)
if block_start_index != -1:
block_end_index = string_value.find(
block_border, (block_start_index + len(block_start_border))
)
return (
(True, block_start_index, block_end_index)
if block_end_index != -1
else (False, None, None)
)
return (False, None, None)
def get_code_block(
string_value: str, start: int, end: int, code_type: Optional[str] = ""
) -> str:
"""Takes a string, start and end indexes, and an optional code type to
extract a code block from a string.
Args::
string_value (str): The string to extract a the code block from.
start (int): The starting index of the code block. This is assumed to be inclusive of the block boundaries.
end (int): The ending index of the code block. This is assumed to be inclusive of the block boundaries.
code_type (str, optional): The specific code type to check for. Defaults to empty string.
Returns:
str: The contents of the code block.
""" # noqa
trimmed_input = string_value
block_border = "```"
block_start_border = f"{block_border}{code_type}"
start_index = start + len(block_start_border)
contents = trimmed_input[start_index:end]
trimmed_output = contents.strip()
return trimmed_output
def extract_json_from_ouput(
output: str,
) -> Tuple[Optional[Union[Dict, List]], Optional[Exception]]:
# try to load the whole output as json first
# there can be corner cases with code blocks
# and json/codeblocks inside json
try:
output_as_dict = json.loads(output, strict=False)
return output_as_dict, None
except json.decoder.JSONDecodeError:
pass
# Find and extract json from code blocks
extracted_code_block = output
has_json_block, json_start, json_end = has_code_block(output, "json")
if has_json_block and json_start is not None and json_end is not None:
extracted_code_block = get_code_block(output, json_start, json_end, "json")
else:
has_block, block_start, block_end = has_code_block(output)
if has_block and block_start is not None and block_end is not None:
extracted_code_block = get_code_block(output, block_start, block_end)
else:
json_pattern = regex.compile(r"\{(?:[^{}]+|\{(?:(?R)|[^{}]+)*\})*\}")
json_groups = json_pattern.findall(output)
json_start, json_end = output.find("{"), output.rfind("}")
if len(json_groups) > 0 and len(json_groups[0]) == (
json_end - json_start + 1
):
extracted_code_block = json_groups[0]
# Treat the output as a JSON string, and load it into a dict.
error = None
try:
output_as_dict = json.loads(extracted_code_block, strict=False)
except json.decoder.JSONDecodeError as e:
output_as_dict = None
error = e
return output_as_dict, error
### Streaming Fragment Parsing ###
def is_valid_fragment(fragment: str, verified: set) -> bool:
"""Check if the fragment is a somewhat valid JSON."""
# Strip fragment of whitespaces and newlines
# to avoid duplicate checks
text = fragment.strip(" \n")
# Check if text is already verified
if text in verified:
return False
# Check if text is valid JSON
try:
json.loads(text)
verified.add(text)
return True
except ValueError as e:
error_msg = str(e)
# Check if error is due to missing comma
if "Expecting ',' delimiter" in error_msg:
verified.add(text)
return True
return False
def parse_fragment(fragment: str) -> Tuple[Union[str, List, Dict], Optional[str]]:
"""Parse the fragment into a dict."""
# Complete the JSON fragment to handle missing brackets
# Stack to keep track of opening brackets
stack = []
# Process each character in the string
for char in fragment:
if char in "{[":
# Push opening brackets onto the stack
stack.append(char)
elif char in "}]":
# Pop from stack if matching opening bracket is found
if stack and (
(char == "}" and stack[-1] == "{") or (char == "]" and stack[-1] == "[")
):
stack.pop()
# Add the necessary closing brackets in reverse order
while stack:
opening_bracket = stack.pop()
if opening_bracket == "{":
fragment += "}"
elif opening_bracket == "[":
fragment += "]"
# Parse the fragment
try:
parsed_fragment: Union[Dict, List] = json.loads(fragment)
return parsed_fragment, None
except ValueError as e:
return fragment, str(e)
### LLM Output Parsing ###
def parse_json_llm_output(
output: str, **kwargs
) -> Tuple[
Union[str, List, Dict, NonParseableReAsk, None],
Union[Optional[Exception], str, bool, None],
]:
if kwargs.get("stream", False):
# Do expected behavior for StreamRunner
# 1. Check if the fragment is valid JSON
verified = kwargs.get("verified", set())
fragment_is_valid = is_valid_fragment(output, verified)
if not fragment_is_valid:
return output, True
# 2. Parse the fragment
parsed_fragment, parsing_error = parse_fragment(output)
return parsed_fragment, parsing_error
# Else do expected behavior for Runner
# Try to get json code block from output.
# Return error and reask if it is not parseable.
parsed_output, error = extract_json_from_ouput(output)
if error:
reask = NonParseableReAsk(
incorrectValue=output,
failResults=[
FailResult(
fixValue=None,
errorMessage="Output is not parseable as JSON",
)
],
)
return reask, error
return parsed_output, None
def parse_string_llm_output(output: str) -> Tuple[str, Optional[Exception]]:
# Return a ValueError if the output is empty, else None
error = ValueError("Empty response received.") if not output else None
return output, error
def parse_llm_output(output: str, output_type: OutputTypes, **kwargs):
if output_type == OutputTypes.STRING:
return parse_string_llm_output(output)
return parse_json_llm_output(output, **kwargs)
def prune_extra_keys(
payload: Union[str, List[Any], Dict[str, Any]],
schema: Dict[str, Any],
*,
json_path: str = "$",
all_json_paths: Optional[Set[str]] = None,
) -> Union[str, List[Any], Dict[str, Any]]:
if all_json_paths is None or not len(all_json_paths):
all_json_paths = get_all_paths(schema)
if isinstance(payload, dict):
# Do full lookbehind
wildcards: List[str] = [
path.split(".*")[0] for path in all_json_paths if ".*" in path
]
ancestor_is_wildcard = any(w in json_path for w in wildcards)
actual_keys = list(payload.keys())
for key in actual_keys:
child_path = f"{json_path}.{key}"
if child_path not in all_json_paths and not ancestor_is_wildcard:
del payload[key]
else:
prune_extra_keys(
payload=payload.get(key), # type: ignore
schema=schema,
json_path=child_path,
all_json_paths=all_json_paths,
)
elif isinstance(payload, list):
for item in payload:
prune_extra_keys(
payload=item,
schema=schema,
json_path=json_path,
all_json_paths=all_json_paths,
)
return payload
def coerce(value: Any, desired_type: Callable) -> Any:
try:
coerced_value = desired_type(value)
return coerced_value
except (ValueError, TypeError):
return value
def try_json_parse(value: str) -> Any:
try:
return json.loads(value)
except Exception:
return value
def coerce_to_type(
payload: Union[str, List[Any], Dict[str, Any], Any], schema_type: SimpleTypes
) -> Any:
if schema_type == SimpleTypes.ARRAY:
if isinstance(payload, str):
payload = try_json_parse(payload)
if not isinstance(payload, list):
return coerce(payload, list)
return payload
elif schema_type == SimpleTypes.BOOLEAN:
if not isinstance(payload, bool):
return coerce(payload, bool)
return payload
elif schema_type == SimpleTypes.INTEGER:
if not isinstance(payload, int):
val = coerce(payload, int)
return val
return payload
elif schema_type == SimpleTypes.NULL:
return None
elif schema_type == SimpleTypes.NUMBER:
if not isinstance(payload, float):
return coerce(payload, float)
return payload
elif schema_type == SimpleTypes.OBJECT:
if isinstance(payload, str):
payload = try_json_parse(payload)
if not isinstance(payload, dict):
return coerce(payload, dict)
return payload
elif schema_type == SimpleTypes.STRING:
if not isinstance(payload, str) and not isinstance(payload, (list, dict)):
return coerce(payload, str)
return payload
def coerce_property(
payload: Union[str, List[Any], Dict[str, Any], Any], schema: Dict[str, Any]
) -> Union[str, List[Any], Dict[str, Any]]:
schema_type = schema.get("type")
if schema_type:
payload = coerce_to_type(payload, schema_type)
### Schema Composition ###
one_of = schema.get("oneOf")
if one_of:
possible_values = []
for sub_schema in one_of:
possible_values.append(coerce_property(payload, sub_schema))
payload = safe_get(list(filter(None, possible_values)), 0, payload)
any_of = schema.get("anyOf")
if any_of:
possible_values = []
for sub_schema in any_of:
possible_values.append(coerce_property(payload, sub_schema))
payload = safe_get(list(filter(None, possible_values)), 0, payload)
all_of: List[Dict[str, Any]] = schema.get("allOf", [])
if all_of:
if_blocks = [sub for sub in all_of if sub.get("if")]
if if_blocks:
for if_block in if_blocks:
factored_schema = {**schema, **if_block}
factored_schema.pop("allOf", {})
payload = coerce_property(payload, factored_schema)
other_blocks = [sub for sub in all_of if not sub.get("if")]
for sub_schema in other_blocks:
payload = coerce_property(payload, sub_schema)
else:
factored_schema = {**schema}
factored_schema.pop("allOf")
for sub_schema in all_of:
factored_schema = {**schema, **sub_schema}
payload = coerce_property(payload, factored_schema)
### Object Schema ###
properties: Dict[str, Any] = schema.get("properties", {})
if properties and isinstance(payload, dict):
for k, v in properties.items():
payload_value = payload.get(k)
if payload_value:
payload[k] = coerce_property(payload_value, v)
### Object Additional Properties ###
additional_properties_schema: Dict[str, Any] = schema.get(
"additionalProperties", {}
)
if isinstance(additional_properties_schema, bool):
additional_properties_schema = {}
if additional_properties_schema and isinstance(payload, dict):
declared_properties = properties.keys()
additional_properties = [
key for key in payload.keys() if key not in declared_properties
]
for prop in additional_properties:
payload_value = payload.get(prop)
if payload_value:
payload[prop] = coerce_property(
payload_value, additional_properties_schema
)
### Conditional SubSchema ###
if_block: Dict[str, Any] = schema.get("if", {})
if if_block and isinstance(payload, dict):
if_properties: Dict[str, Any] = if_block.get("properties", {})
then_block: Dict[str, Any] = schema.get("then", {})
then_properties: Dict[str, Any] = then_block.get("properties", {})
else_block: Dict[str, Any] = schema.get("else", {})
else_properties: Dict[str, Any] = else_block.get("properties", {})
conditional_schema = else_properties
condition_satisfied = True
for k, v in if_properties.items():
actual_value = safe_get(payload, k)
condition_value = safe_get(v, "const")
condition_satisfied = (
condition_satisfied and actual_value == condition_value
)
if condition_satisfied:
conditional_schema = then_properties
factored_schema = {**schema, "properties": {**properties, **conditional_schema}}
factored_schema.pop("if", {})
factored_schema.pop("then", {})
factored_schema.pop("else", {})
payload = coerce_property(payload, factored_schema)
### Array Schema ###
item_schema: Dict[str, Any] = schema.get("items", {})
if isinstance(payload, list) and item_schema:
coerced_items = []
for item in payload:
coerced_items.append(coerce_property(item, item_schema))
payload = coerced_items
return payload
def coerce_types(
payload: Union[str, List[Any], Dict[str, Any], Any], schema: Dict[str, Any]
) -> Union[str, List[Any], Dict[str, Any]]:
dereferenced_schema = cast(
Dict[str, Any], jsonref.replace_refs(schema)
) # for pyright
return coerce_property(payload, dereferenced_schema)