참고소스 수정본
This commit is contained in:
13
참고/guardrails-main/guardrails/run/__init__.py
Normal file
13
참고/guardrails-main/guardrails/run/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from guardrails.run.async_runner import AsyncRunner
|
||||
from guardrails.run.runner import Runner
|
||||
from guardrails.run.stream_runner import StreamRunner
|
||||
from guardrails.run.async_stream_runner import AsyncStreamRunner
|
||||
from guardrails.run.utils import messages_source
|
||||
|
||||
__all__ = [
|
||||
"Runner",
|
||||
"AsyncRunner",
|
||||
"StreamRunner",
|
||||
"AsyncStreamRunner",
|
||||
"messages_source",
|
||||
]
|
||||
378
참고/guardrails-main/guardrails/run/async_runner.py
Normal file
378
참고/guardrails-main/guardrails/run/async_runner.py
Normal file
@@ -0,0 +1,378 @@
|
||||
import copy
|
||||
from functools import partial
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
|
||||
|
||||
from guardrails import validator_service
|
||||
from guardrails.classes.execution.guard_execution_options import GuardExecutionOptions
|
||||
from guardrails.classes.history import Call, Inputs, Iteration, Outputs
|
||||
from guardrails.classes.output_type import OutputTypes
|
||||
from guardrails.errors import ValidationError
|
||||
from guardrails.llm_providers import AsyncPromptCallableBase
|
||||
from guardrails.logger import set_scope
|
||||
from guardrails.run.runner import Runner
|
||||
from guardrails.run.utils import messages_source
|
||||
from guardrails.schema.validator import schema_validation
|
||||
from guardrails.hub_telemetry.hub_tracing import async_trace
|
||||
from guardrails.types.inputs import MessageHistory
|
||||
from guardrails.types.pydantic import ModelOrListOfModels
|
||||
from guardrails.types.validator import ValidatorMap
|
||||
from guardrails.utils.exception_utils import UserFacingException
|
||||
from guardrails.classes.llm.llm_response import LLMResponse
|
||||
from guardrails.actions.reask import NonParseableReAsk, ReAsk
|
||||
from guardrails.telemetry import trace_async_call, trace_async_step
|
||||
|
||||
from guardrails.constants import fail_status
|
||||
from guardrails.prompt import Prompt
|
||||
|
||||
|
||||
class AsyncRunner(Runner):
|
||||
def __init__(
|
||||
self,
|
||||
output_type: OutputTypes,
|
||||
output_schema: Dict[str, Any],
|
||||
num_reasks: int,
|
||||
validation_map: ValidatorMap,
|
||||
*,
|
||||
messages: Optional[List[Dict]] = None,
|
||||
api: Optional[AsyncPromptCallableBase] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
output: Optional[str] = None,
|
||||
base_model: Optional[ModelOrListOfModels] = None,
|
||||
full_schema_reask: bool = False,
|
||||
disable_tracer: Optional[bool] = True,
|
||||
exec_options: Optional[GuardExecutionOptions] = None,
|
||||
):
|
||||
super().__init__(
|
||||
output_type=output_type,
|
||||
output_schema=output_schema,
|
||||
num_reasks=num_reasks,
|
||||
validation_map=validation_map,
|
||||
messages=messages,
|
||||
api=api,
|
||||
metadata=metadata,
|
||||
output=output,
|
||||
base_model=base_model,
|
||||
full_schema_reask=full_schema_reask,
|
||||
disable_tracer=disable_tracer,
|
||||
exec_options=exec_options,
|
||||
)
|
||||
self.api = api
|
||||
|
||||
# TODO: Refactor this to use inheritance and overrides
|
||||
# Why are we using a different method here instead of just overriding?
|
||||
@async_trace(name="/reasks", origin="AsyncRunner.async_run")
|
||||
async def async_run(
|
||||
self, call_log: Call, prompt_params: Optional[Dict] = None
|
||||
) -> Call:
|
||||
"""Execute the runner by repeatedly calling step until the reask budget
|
||||
is exhausted.
|
||||
|
||||
Args:
|
||||
prompt_params: Parameters to pass to the prompt in order to
|
||||
generate the prompt string.
|
||||
|
||||
Returns:
|
||||
The Call log for this run.
|
||||
"""
|
||||
prompt_params = prompt_params or {}
|
||||
try:
|
||||
(
|
||||
messages,
|
||||
output_schema,
|
||||
) = (
|
||||
self.messages,
|
||||
self.output_schema,
|
||||
)
|
||||
index = 0
|
||||
for index in range(self.num_reasks + 1):
|
||||
# Run a single step.
|
||||
iteration = await self.async_step(
|
||||
index=index,
|
||||
api=self.api,
|
||||
messages=messages,
|
||||
prompt_params=prompt_params,
|
||||
output_schema=output_schema,
|
||||
output=self.output if index == 0 else None,
|
||||
call_log=call_log,
|
||||
)
|
||||
|
||||
# Loop again?
|
||||
if not self.do_loop(index, iteration.reasks):
|
||||
break
|
||||
|
||||
# Get new prompt and output schema.
|
||||
(
|
||||
output_schema,
|
||||
messages,
|
||||
) = self.prepare_to_loop(
|
||||
iteration.reasks,
|
||||
output_schema,
|
||||
parsed_output=iteration.outputs.parsed_output,
|
||||
validated_output=call_log.validation_response,
|
||||
prompt_params=prompt_params,
|
||||
)
|
||||
|
||||
except UserFacingException as e:
|
||||
# Because Pydantic v1 doesn't respect property setters
|
||||
call_log.exception = e.original_exception
|
||||
raise e.original_exception
|
||||
except Exception as e:
|
||||
# Because Pydantic v1 doesn't respect property setters
|
||||
call_log.exception = e
|
||||
raise e
|
||||
|
||||
return call_log
|
||||
|
||||
# TODO: Refactor this to use inheritance and overrides
|
||||
@async_trace(name="/step", origin="AsyncRunner.async_step")
|
||||
@trace_async_step
|
||||
async def async_step(
|
||||
self,
|
||||
index: int,
|
||||
output_schema: Dict[str, Any],
|
||||
call_log: Call,
|
||||
*,
|
||||
api: Optional[AsyncPromptCallableBase],
|
||||
messages: Optional[List[Dict]] = None,
|
||||
prompt_params: Optional[Dict] = None,
|
||||
output: Optional[str] = None,
|
||||
) -> Iteration:
|
||||
"""Run a full step."""
|
||||
prompt_params = prompt_params or {}
|
||||
inputs = Inputs(
|
||||
llm_api=api,
|
||||
llm_output=output,
|
||||
messages=messages,
|
||||
prompt_params=prompt_params,
|
||||
num_reasks=self.num_reasks,
|
||||
metadata=self.metadata,
|
||||
full_schema_reask=self.full_schema_reask,
|
||||
)
|
||||
outputs = Outputs()
|
||||
iteration = Iteration(
|
||||
callId=call_log.id, index=index, inputs=inputs, outputs=outputs
|
||||
)
|
||||
set_scope(str(id(iteration)))
|
||||
call_log.iterations.push(iteration)
|
||||
|
||||
try:
|
||||
# Prepare: run pre-processing, and input validation.
|
||||
if output is not None:
|
||||
messages = None
|
||||
else:
|
||||
messages = await self.async_prepare(
|
||||
call_log,
|
||||
messages=messages,
|
||||
prompt_params=prompt_params,
|
||||
api=api,
|
||||
attempt_number=index,
|
||||
)
|
||||
|
||||
iteration.inputs.messages = messages
|
||||
|
||||
# Call: run the API.
|
||||
llm_response = await self.async_call(messages, api, output)
|
||||
|
||||
iteration.outputs.llm_response_info = llm_response
|
||||
output = llm_response.output
|
||||
|
||||
# Parse: parse the output.
|
||||
parsed_output, parsing_error = self.parse(output, output_schema)
|
||||
if parsing_error or isinstance(parsed_output, ReAsk):
|
||||
iteration.outputs.exception = parsing_error # type: ignore # pyright and pydantic don't agree
|
||||
iteration.outputs.error = str(parsing_error)
|
||||
iteration.outputs.reasks.append(parsed_output) # type: ignore # pyright and pydantic don't agree
|
||||
else:
|
||||
iteration.outputs.parsed_output = parsed_output # type: ignore # pyright and pydantic don't agree
|
||||
|
||||
if parsing_error and isinstance(parsed_output, NonParseableReAsk):
|
||||
reasks, _ = self.introspect(parsed_output)
|
||||
else:
|
||||
# Validate: run output validation.
|
||||
validated_output = await self.async_validate(
|
||||
iteration, index, parsed_output, output_schema
|
||||
)
|
||||
iteration.outputs.validation_response = validated_output
|
||||
|
||||
# Introspect: inspect validated output for reasks.
|
||||
reasks, valid_output = self.introspect(validated_output)
|
||||
iteration.outputs.guarded_output = valid_output
|
||||
|
||||
iteration.outputs.reasks = reasks # type: ignore # pyright and pydantic don't agree
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
iteration.outputs.error = error_message
|
||||
iteration.outputs.exception = e
|
||||
raise e
|
||||
return iteration
|
||||
|
||||
# TODO: Refactor this to use inheritance and overrides
|
||||
@async_trace(name="/llm_call", origin="AsyncRunner.async_call")
|
||||
@trace_async_call
|
||||
async def async_call(
|
||||
self,
|
||||
messages: Optional[List[Dict]],
|
||||
api: Optional[AsyncPromptCallableBase],
|
||||
output: Optional[str] = None,
|
||||
) -> LLMResponse:
|
||||
"""Run a step.
|
||||
|
||||
1. Query the LLM API,
|
||||
2. Convert the response string to a dict,
|
||||
3. Log the output
|
||||
"""
|
||||
# If the API supports a base model, pass it in.
|
||||
api_fn = api
|
||||
if api is not None:
|
||||
supports_base_model = getattr(api, "supports_base_model", False)
|
||||
if supports_base_model:
|
||||
api_fn = partial(api, base_model=self.base_model)
|
||||
if output is not None:
|
||||
llm_response = LLMResponse(
|
||||
output=output,
|
||||
)
|
||||
elif api_fn is None:
|
||||
raise ValueError("API or output must be provided.")
|
||||
elif messages:
|
||||
llm_response = await api_fn(messages=messages_source(messages))
|
||||
else:
|
||||
llm_response = await api_fn()
|
||||
return llm_response
|
||||
|
||||
# TODO: Refactor this to use inheritance and overrides
|
||||
@async_trace(name="/validation", origin="AsyncRunner.async_validate")
|
||||
async def async_validate(
|
||||
self,
|
||||
iteration: Iteration,
|
||||
attempt_number: int,
|
||||
parsed_output: Any,
|
||||
output_schema: Dict[str, Any],
|
||||
stream: Optional[bool] = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""Validate the output."""
|
||||
# Break early if empty
|
||||
if parsed_output is None:
|
||||
return None
|
||||
|
||||
skeleton_reask = schema_validation(parsed_output, output_schema, **kwargs)
|
||||
if skeleton_reask:
|
||||
return skeleton_reask
|
||||
|
||||
if self.output_type != OutputTypes.STRING:
|
||||
stream = None
|
||||
|
||||
validated_output, metadata = await validator_service.async_validate(
|
||||
value=parsed_output,
|
||||
metadata=self.metadata,
|
||||
validator_map=self.validation_map,
|
||||
iteration=iteration,
|
||||
disable_tracer=self._disable_tracer,
|
||||
path="$",
|
||||
stream=stream,
|
||||
**kwargs,
|
||||
)
|
||||
self.metadata.update(metadata)
|
||||
validated_output = validator_service.post_process_validation(
|
||||
validated_output, attempt_number, iteration, self.output_type
|
||||
)
|
||||
|
||||
return validated_output
|
||||
|
||||
# TODO: Refactor this to use inheritance and overrides
|
||||
@async_trace(name="/input_prep", origin="AsyncRunner.async_prepare")
|
||||
async def async_prepare(
|
||||
self,
|
||||
call_log: Call,
|
||||
attempt_number: int,
|
||||
*,
|
||||
messages: Optional[List[Dict]],
|
||||
prompt_params: Optional[Dict] = None,
|
||||
api: Optional[AsyncPromptCallableBase],
|
||||
) -> Optional[List[Dict]]:
|
||||
"""Prepare by running pre-processing and input validation.
|
||||
|
||||
Returns:
|
||||
The messages.
|
||||
"""
|
||||
prompt_params = prompt_params or {}
|
||||
if api is None:
|
||||
raise UserFacingException(ValueError("API must be provided."))
|
||||
|
||||
if messages:
|
||||
# Runner.prepare_messages
|
||||
messages = await self.prepare_messages(
|
||||
call_log=call_log,
|
||||
messages=messages,
|
||||
prompt_params=prompt_params,
|
||||
attempt_number=attempt_number,
|
||||
)
|
||||
|
||||
else:
|
||||
raise UserFacingException(ValueError("'messages' must be provided."))
|
||||
|
||||
return messages
|
||||
|
||||
async def prepare_messages(
|
||||
self,
|
||||
call_log: Call,
|
||||
messages: MessageHistory,
|
||||
prompt_params: Dict,
|
||||
attempt_number: int,
|
||||
) -> MessageHistory:
|
||||
formatted_messages = []
|
||||
|
||||
# Format any variables in the message history with the prompt params.
|
||||
for msg in messages:
|
||||
msg_copy = copy.deepcopy(msg)
|
||||
if attempt_number == 0:
|
||||
msg_copy["content"] = msg_copy["content"].format(**prompt_params)
|
||||
formatted_messages.append(msg_copy)
|
||||
|
||||
if "messages" in self.validation_map:
|
||||
await self.validate_messages(call_log, formatted_messages, attempt_number)
|
||||
|
||||
return formatted_messages
|
||||
|
||||
@async_trace(name="/input_validation", origin="AsyncRunner.validate_messages")
|
||||
async def validate_messages(
|
||||
self, call_log: Call, messages: MessageHistory, attempt_number: int
|
||||
):
|
||||
for msg in messages:
|
||||
content = (
|
||||
msg["content"].source
|
||||
if isinstance(msg["content"], Prompt)
|
||||
else msg["content"]
|
||||
)
|
||||
inputs = Inputs(
|
||||
llm_output=content,
|
||||
)
|
||||
iteration = Iteration(
|
||||
callId=call_log.id, index=attempt_number, inputs=inputs
|
||||
)
|
||||
call_log.iterations.insert(0, iteration)
|
||||
value, _metadata = await validator_service.async_validate(
|
||||
value=content,
|
||||
metadata=self.metadata,
|
||||
validator_map=self.validation_map,
|
||||
iteration=iteration,
|
||||
disable_tracer=self._disable_tracer,
|
||||
path="messages",
|
||||
)
|
||||
|
||||
validated_msg = validator_service.post_process_validation(
|
||||
value, attempt_number, iteration, OutputTypes.STRING
|
||||
)
|
||||
|
||||
iteration.outputs.validation_response = validated_msg
|
||||
|
||||
if isinstance(validated_msg, ReAsk):
|
||||
raise ValidationError(f"Messages validation failed: {validated_msg}")
|
||||
elif not validated_msg or iteration.status == fail_status:
|
||||
raise ValidationError("Messages validation failed")
|
||||
|
||||
msg["content"] = cast(str, validated_msg)
|
||||
|
||||
return messages # type: ignore
|
||||
340
참고/guardrails-main/guardrails/run/async_stream_runner.py
Normal file
340
참고/guardrails-main/guardrails/run/async_stream_runner.py
Normal file
@@ -0,0 +1,340 @@
|
||||
from contextvars import ContextVar, copy_context
|
||||
import sys
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterator,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
cast,
|
||||
)
|
||||
|
||||
from guardrails.validator_service import AsyncValidatorService
|
||||
from guardrails.actions.reask import SkeletonReAsk
|
||||
from guardrails.classes import ValidationOutcome
|
||||
from guardrails.classes.history import Call, Inputs, Iteration, Outputs
|
||||
from guardrails.classes.output_type import OutputTypes
|
||||
from guardrails.llm_providers import (
|
||||
AsyncPromptCallableBase,
|
||||
)
|
||||
from guardrails.logger import set_scope
|
||||
from guardrails.run import StreamRunner
|
||||
from guardrails.run.async_runner import AsyncRunner
|
||||
from guardrails.telemetry import trace_async_stream_step
|
||||
from guardrails.hub_telemetry.hub_tracing import async_trace_stream
|
||||
from guardrails.types import OnFailAction
|
||||
from guardrails_ai.types import (
|
||||
PassResult,
|
||||
FailResult,
|
||||
)
|
||||
|
||||
|
||||
if sys.version_info.minor < 10:
|
||||
from guardrails.utils.polyfills import anext
|
||||
|
||||
|
||||
class AsyncStreamRunner(AsyncRunner, StreamRunner):
|
||||
# @async_trace_stream(name="/reasks", origin="AsyncStreamRunner.async_run")
|
||||
async def async_run(
|
||||
self, call_log: Call, prompt_params: Optional[Dict] = None
|
||||
) -> AsyncIterator[ValidationOutcome]:
|
||||
prompt_params = prompt_params or {}
|
||||
|
||||
(
|
||||
messages,
|
||||
output_schema,
|
||||
) = (
|
||||
self.messages,
|
||||
self.output_schema,
|
||||
)
|
||||
|
||||
result = await self.async_step(
|
||||
0,
|
||||
output_schema,
|
||||
call_log,
|
||||
api=self.api,
|
||||
messages=messages,
|
||||
prompt_params=prompt_params,
|
||||
output=self.output,
|
||||
)
|
||||
# FIXME: Where can this be moved to be less verbose? This is an await call on
|
||||
# the async generator.
|
||||
async for call in result:
|
||||
yield call
|
||||
|
||||
@async_trace_stream(name="/step", origin="AsyncStreamRunner.async_step")
|
||||
@trace_async_stream_step
|
||||
async def async_step(
|
||||
self,
|
||||
index: int,
|
||||
output_schema: Dict[str, Any],
|
||||
call_log: Call,
|
||||
*,
|
||||
api: Optional[AsyncPromptCallableBase],
|
||||
messages: Optional[List[Dict]] = None,
|
||||
prompt_params: Optional[Dict] = None,
|
||||
output: Optional[str] = None,
|
||||
) -> AsyncIterator[ValidationOutcome]:
|
||||
prompt_params = prompt_params or {}
|
||||
inputs = Inputs(
|
||||
llm_api=api,
|
||||
llm_output=output,
|
||||
messages=messages,
|
||||
prompt_params=prompt_params,
|
||||
num_reasks=self.num_reasks,
|
||||
metadata=self.metadata,
|
||||
full_schema_reask=self.full_schema_reask,
|
||||
stream=True,
|
||||
)
|
||||
outputs = Outputs()
|
||||
iteration = Iteration(
|
||||
callId=call_log.id, index=index, inputs=inputs, outputs=outputs
|
||||
)
|
||||
set_scope(str(id(iteration)))
|
||||
call_log.iterations.push(iteration)
|
||||
if output is not None:
|
||||
messages = None
|
||||
else:
|
||||
messages = await self.async_prepare(
|
||||
call_log,
|
||||
messages=messages,
|
||||
prompt_params=prompt_params,
|
||||
api=api,
|
||||
attempt_number=index,
|
||||
)
|
||||
|
||||
iteration.inputs.messages = messages
|
||||
|
||||
llm_response = await self.async_call(messages, api, output)
|
||||
iteration.outputs.llm_response_info = llm_response
|
||||
stream_output = llm_response.async_stream_output
|
||||
if stream_output is None:
|
||||
raise ValueError(
|
||||
"No async stream was returned from the API. Please check that "
|
||||
"the API is returning an async generator."
|
||||
)
|
||||
|
||||
fragment = ""
|
||||
parsed_fragment, validated_fragment, valid_op = None, None, None
|
||||
verified = set()
|
||||
validation_response = ""
|
||||
validation_progress = {}
|
||||
refrain_triggered = False
|
||||
validation_passed = True
|
||||
|
||||
context = copy_context()
|
||||
stream_context_vars: ContextVar[Dict[str, ContextVar[List[str]]]] = ContextVar(
|
||||
"stream_context"
|
||||
)
|
||||
context_vars: Dict[str, ContextVar[List[str]]] = {}
|
||||
for k, v in self.validation_map.items():
|
||||
if isinstance(v, list):
|
||||
for validator in v:
|
||||
property_validation_chunks = ContextVar(
|
||||
f"{k}_{validator.rail_alias}_chunks"
|
||||
)
|
||||
context.run(property_validation_chunks.set, [])
|
||||
context_vars[f"{k}_{validator.rail_alias}"] = (
|
||||
property_validation_chunks # noqa: E501
|
||||
)
|
||||
context.run(stream_context_vars.set, context_vars)
|
||||
|
||||
if self.output_type == OutputTypes.STRING:
|
||||
validator_service = AsyncValidatorService(self.disable_tracer)
|
||||
|
||||
next_exists = True
|
||||
while next_exists:
|
||||
try:
|
||||
chunk = await anext(stream_output)
|
||||
chunk_text = self.get_chunk_text(chunk, api)
|
||||
_ = self.is_last_chunk(chunk, api)
|
||||
|
||||
fragment += chunk_text
|
||||
|
||||
results = await validator_service.async_partial_validate(
|
||||
chunk_text,
|
||||
self.metadata,
|
||||
self.validation_map,
|
||||
iteration,
|
||||
"$",
|
||||
"$",
|
||||
True,
|
||||
context=context,
|
||||
context_vars=stream_context_vars,
|
||||
)
|
||||
validators = self.validation_map.get("$", [])
|
||||
|
||||
# collect the result validated_chunk into validation progress
|
||||
# per validator
|
||||
for result in results:
|
||||
validator_log = result.validator_logs # type: ignore
|
||||
validator = next(
|
||||
filter(
|
||||
lambda x: x.rail_alias == validator_log.registered_name,
|
||||
validators,
|
||||
),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
validator_log.validation_result
|
||||
and validator_log.validation_result.validated_chunk
|
||||
):
|
||||
is_filter = (
|
||||
validator.on_fail_descriptor is OnFailAction.FILTER # type: ignore
|
||||
)
|
||||
is_refrain = (
|
||||
validator.on_fail_descriptor is OnFailAction.REFRAIN # type: ignore
|
||||
)
|
||||
if validator_log.validation_result.outcome == "fail":
|
||||
validation_passed = False
|
||||
reasks, valid_op = self.introspect(
|
||||
validator_log.validation_result
|
||||
)
|
||||
if reasks:
|
||||
raise ValueError(
|
||||
"Reasks are not yet supported with streaming. "
|
||||
"Please remove reasks from schema or disable"
|
||||
" streaming."
|
||||
)
|
||||
|
||||
if isinstance(validator_log.validation_result, PassResult):
|
||||
chunk = validator_log.validation_result.validated_chunk
|
||||
elif isinstance(
|
||||
validator_log.validation_result, FailResult
|
||||
):
|
||||
if is_filter or is_refrain:
|
||||
refrain_triggered = True
|
||||
chunk = ""
|
||||
else:
|
||||
chunk = validator_service.perform_correction(
|
||||
validator_log.validation_result,
|
||||
validator_log.validation_result.validated_chunk,
|
||||
validator, # type: ignore
|
||||
rechecked_value=None,
|
||||
) # type: ignore
|
||||
|
||||
if validator_log.validator_name not in validation_progress:
|
||||
validation_progress[validator_log.validator_name] = ""
|
||||
|
||||
validation_progress[validator_log.validator_name] += chunk
|
||||
# if there is an entry for every validator
|
||||
# run a merge and emit a validation outcome
|
||||
if (
|
||||
len(validation_progress) == len(validators)
|
||||
or len(validators) == 0
|
||||
):
|
||||
if refrain_triggered:
|
||||
current = ""
|
||||
else:
|
||||
merge_chunks = []
|
||||
for piece in validation_progress:
|
||||
merge_chunks.append(validation_progress[piece])
|
||||
|
||||
current = validator_service.multi_merge(
|
||||
fragment, merge_chunks
|
||||
)
|
||||
|
||||
vo = ValidationOutcome(
|
||||
callId=call_log.id,
|
||||
rawLlmOutput=fragment,
|
||||
validatedOutput=current,
|
||||
validationPassed=True,
|
||||
)
|
||||
fragment = ""
|
||||
validation_progress = {}
|
||||
refrain_triggered = False
|
||||
|
||||
yield vo
|
||||
|
||||
except StopIteration:
|
||||
next_exists = False
|
||||
except StopAsyncIteration:
|
||||
next_exists = False
|
||||
except Exception as e:
|
||||
raise e
|
||||
finally:
|
||||
# reset all context vars
|
||||
for context_var in context_vars.values():
|
||||
token = context.run(context_var.set, [])
|
||||
context.run(context_var.reset, token)
|
||||
token = context.run(stream_context_vars.set, {})
|
||||
context.run(stream_context_vars.reset, token)
|
||||
|
||||
# if theres anything left merge and emit a chunk
|
||||
if len(validation_progress) > 0:
|
||||
merge_chunks = []
|
||||
for piece in validation_progress:
|
||||
merge_chunks.append(validation_progress[piece])
|
||||
|
||||
current = validator_service.multi_merge(fragment, merge_chunks)
|
||||
yield ValidationOutcome(
|
||||
callId=call_log.id,
|
||||
rawLlmOutput=fragment,
|
||||
validatedOutput=current,
|
||||
validationPassed=validation_passed,
|
||||
)
|
||||
else:
|
||||
next_exists = True
|
||||
while next_exists:
|
||||
try:
|
||||
chunk = await anext(stream_output)
|
||||
chunk_text = self.get_chunk_text(chunk, api)
|
||||
fragment += chunk_text
|
||||
|
||||
parsed_fragment, move_to_next = self.parse(
|
||||
fragment, output_schema, verified=verified
|
||||
)
|
||||
if move_to_next:
|
||||
continue
|
||||
validated_fragment = await self.async_validate(
|
||||
iteration,
|
||||
index,
|
||||
parsed_fragment,
|
||||
output_schema,
|
||||
validate_subschema=True,
|
||||
context=context,
|
||||
context_vars=stream_context_vars,
|
||||
)
|
||||
if isinstance(validated_fragment, SkeletonReAsk):
|
||||
raise ValueError(
|
||||
"Received fragment schema is an invalid sub-schema "
|
||||
"of the expected output JSON schema."
|
||||
)
|
||||
|
||||
reasks, valid_op = self.introspect(validated_fragment)
|
||||
if reasks:
|
||||
raise ValueError(
|
||||
"Reasks are not yet supported with streaming. Please "
|
||||
"remove reasks from schema or disable streaming."
|
||||
)
|
||||
|
||||
if self.output_type == OutputTypes.LIST:
|
||||
validation_response = cast(list, validated_fragment)
|
||||
else:
|
||||
validation_response = cast(dict, validated_fragment)
|
||||
yield ValidationOutcome(
|
||||
callId=call_log.id,
|
||||
rawLlmOutput=fragment,
|
||||
validatedOutput=validated_fragment,
|
||||
validationPassed=validated_fragment is not None,
|
||||
)
|
||||
fragment = ""
|
||||
except StopIteration:
|
||||
next_exists = False
|
||||
except StopAsyncIteration:
|
||||
next_exists = False
|
||||
except Exception as e:
|
||||
raise e
|
||||
finally:
|
||||
# reset all context vars
|
||||
for context_var in context_vars.values():
|
||||
token = context.run(context_var.set, [])
|
||||
context.run(context_var.reset, token)
|
||||
token = context.run(stream_context_vars.set, {})
|
||||
context.run(stream_context_vars.reset, token)
|
||||
|
||||
iteration.outputs.raw_output = fragment
|
||||
# FIXME: Handle case where parsing continuously fails/is a reask
|
||||
iteration.outputs.parsed_output = parsed_fragment or fragment # type: ignore
|
||||
iteration.outputs.validation_response = validation_response
|
||||
iteration.outputs.guarded_output = valid_op
|
||||
525
참고/guardrails-main/guardrails/run/runner.py
Normal file
525
참고/guardrails-main/guardrails/run/runner.py
Normal file
@@ -0,0 +1,525 @@
|
||||
import copy
|
||||
from functools import partial
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union, cast
|
||||
|
||||
|
||||
from guardrails import validator_service
|
||||
from guardrails.actions.reask import get_reask_setup
|
||||
from guardrails.classes.execution.guard_execution_options import GuardExecutionOptions
|
||||
from guardrails.classes.history import Call, Inputs, Iteration, Outputs
|
||||
from guardrails.classes.output_type import OutputTypes
|
||||
from guardrails.constants import fail_status
|
||||
from guardrails.errors import ValidationError
|
||||
from guardrails.llm_providers import (
|
||||
AsyncPromptCallableBase,
|
||||
PromptCallableBase,
|
||||
)
|
||||
from guardrails.logger import set_scope
|
||||
from guardrails.prompt import Prompt
|
||||
from guardrails.prompt.messages import Messages
|
||||
from guardrails.run.utils import messages_source
|
||||
from guardrails.schema.rail_schema import json_schema_to_rail_output
|
||||
from guardrails.schema.validator import schema_validation
|
||||
from guardrails.hub_telemetry.hub_tracing import trace
|
||||
from guardrails.types import ModelOrListOfModels, ValidatorMap, MessageHistory
|
||||
from guardrails.utils.exception_utils import UserFacingException
|
||||
from guardrails.utils.hub_telemetry_utils import HubTelemetry
|
||||
from guardrails.classes.llm.llm_response import LLMResponse
|
||||
from guardrails.utils.parsing_utils import (
|
||||
coerce_types,
|
||||
parse_llm_output,
|
||||
prune_extra_keys,
|
||||
)
|
||||
from guardrails.utils.prompt_utils import (
|
||||
prompt_content_for_schema,
|
||||
)
|
||||
from guardrails.actions.reask import NonParseableReAsk, ReAsk, introspect
|
||||
from guardrails.telemetry import trace_call, trace_step
|
||||
|
||||
|
||||
class Runner:
|
||||
"""Runner class that calls an LLM API with a prompt, and performs input and
|
||||
output validation.
|
||||
|
||||
This class will repeatedly call the API until the
|
||||
reask budget is exhausted, or the output is valid.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to use.
|
||||
api: The LLM API to call, which should return a string.
|
||||
output_schema: The output schema to use for validation.
|
||||
num_reasks: The maximum number of times to reask the LLM in case of
|
||||
validation failure, defaults to 0.
|
||||
output: The output to use instead of calling the API, used in cases
|
||||
where the output is already known.
|
||||
"""
|
||||
|
||||
# Validation Inputs
|
||||
output_schema: Dict[str, Any]
|
||||
output_type: OutputTypes
|
||||
validation_map: ValidatorMap = {}
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
# LLM Inputs
|
||||
messages: Optional[List[Dict[str, Union[Prompt, str]]]] = None
|
||||
base_model: Optional[ModelOrListOfModels]
|
||||
exec_options: Optional[GuardExecutionOptions]
|
||||
|
||||
# LLM Calling Details
|
||||
api: Optional[PromptCallableBase] = None
|
||||
output: Optional[str] = None
|
||||
num_reasks: int
|
||||
full_schema_reask: bool = False
|
||||
|
||||
# Internal Metrics Collection
|
||||
disable_tracer: Optional[bool] = True
|
||||
|
||||
# QUESTION: Are any of these init args actually necessary for initialization?
|
||||
# ANSWER: _Maybe_ messages for Prompt initialization
|
||||
# but even that can happen at execution time.
|
||||
# TODO: In versions >=0.6.x, remove this class and just execute a Guard functionally
|
||||
def __init__(
|
||||
self,
|
||||
output_type: OutputTypes,
|
||||
output_schema: Dict[str, Any],
|
||||
num_reasks: int,
|
||||
validation_map: ValidatorMap,
|
||||
*,
|
||||
messages: Optional[List[Dict]] = None,
|
||||
api: Optional[PromptCallableBase] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
output: Optional[str] = None,
|
||||
base_model: Optional[ModelOrListOfModels] = None,
|
||||
full_schema_reask: bool = False,
|
||||
disable_tracer: Optional[bool] = True,
|
||||
exec_options: Optional[GuardExecutionOptions] = None,
|
||||
):
|
||||
# Validation Inputs
|
||||
self.output_type = output_type
|
||||
self.output_schema = output_schema
|
||||
self.validation_map = validation_map
|
||||
self.metadata = metadata or {}
|
||||
self.exec_options = copy.deepcopy(exec_options) or GuardExecutionOptions()
|
||||
|
||||
# LLM Inputs
|
||||
|
||||
stringified_output_schema = prompt_content_for_schema(
|
||||
output_type, output_schema, validation_map
|
||||
)
|
||||
xml_output_schema = json_schema_to_rail_output(
|
||||
json_schema=output_schema, validator_map=validation_map
|
||||
)
|
||||
|
||||
if messages:
|
||||
self.exec_options.messages = messages
|
||||
messages_copy = []
|
||||
for msg in messages:
|
||||
msg_copy = copy.deepcopy(msg)
|
||||
msg_copy["content"] = Prompt(
|
||||
msg_copy["content"],
|
||||
output_schema=stringified_output_schema,
|
||||
xml_output_schema=xml_output_schema,
|
||||
)
|
||||
messages_copy.append(msg_copy)
|
||||
self.messages = messages_copy
|
||||
|
||||
self.base_model = base_model
|
||||
|
||||
# LLM Calling Details
|
||||
self.api = api
|
||||
self.output = output
|
||||
self.num_reasks = num_reasks
|
||||
self.full_schema_reask = full_schema_reask
|
||||
|
||||
# Internal Metrics Collection
|
||||
# Get metrics opt-out from credentials
|
||||
self._disable_tracer = disable_tracer
|
||||
|
||||
# Get the HubTelemetry singleton
|
||||
self._hub_telemetry = HubTelemetry()
|
||||
self._hub_telemetry._enabled = not self._disable_tracer
|
||||
|
||||
@trace(name="/reasks", origin="Runner.__call__")
|
||||
def __call__(self, call_log: Call, prompt_params: Optional[Dict] = None) -> Call:
|
||||
"""Execute the runner by repeatedly calling step until the reask budget
|
||||
is exhausted.
|
||||
|
||||
Args:
|
||||
prompt_params: Parameters to pass to the prompt in order to
|
||||
generate the prompt string.
|
||||
|
||||
Returns:
|
||||
The Call log for this run.
|
||||
"""
|
||||
prompt_params = prompt_params or {}
|
||||
try:
|
||||
# NOTE: At first glance this seems gratuitous,
|
||||
# but these local variables are reassigned after
|
||||
# calling self.prepare_to_loop
|
||||
(
|
||||
messages,
|
||||
output_schema,
|
||||
) = (
|
||||
self.messages,
|
||||
self.output_schema,
|
||||
)
|
||||
|
||||
index = 0
|
||||
for index in range(self.num_reasks + 1):
|
||||
# Run a single step.
|
||||
iteration = self.step(
|
||||
index=index,
|
||||
api=self.api,
|
||||
messages=messages,
|
||||
prompt_params=prompt_params,
|
||||
output_schema=output_schema,
|
||||
output=self.output if index == 0 else None,
|
||||
call_log=call_log,
|
||||
)
|
||||
|
||||
# Loop again?
|
||||
if not self.do_loop(index, iteration.reasks):
|
||||
break
|
||||
|
||||
# Get new prompt and output schema.
|
||||
(output_schema, messages) = self.prepare_to_loop(
|
||||
iteration.reasks,
|
||||
output_schema,
|
||||
parsed_output=iteration.outputs.parsed_output,
|
||||
validated_output=call_log.validation_response,
|
||||
prompt_params=prompt_params,
|
||||
)
|
||||
|
||||
except UserFacingException as e:
|
||||
# Because Pydantic v1 doesn't respect property setters
|
||||
call_log.exception = e.original_exception
|
||||
raise e.original_exception
|
||||
except Exception as e:
|
||||
# Because Pydantic v1 doesn't respect property setters
|
||||
call_log.exception = e
|
||||
raise e
|
||||
return call_log
|
||||
|
||||
@trace(name="/step", origin="Runner.step")
|
||||
@trace_step
|
||||
def step(
|
||||
self,
|
||||
index: int,
|
||||
output_schema: Dict[str, Any],
|
||||
call_log: Call,
|
||||
*,
|
||||
api: Optional[PromptCallableBase],
|
||||
messages: Optional[List[Dict]] = None,
|
||||
prompt_params: Optional[Dict] = None,
|
||||
output: Optional[str] = None,
|
||||
) -> Iteration:
|
||||
"""Run a full step."""
|
||||
prompt_params = prompt_params or {}
|
||||
inputs = Inputs(
|
||||
llm_api=api,
|
||||
llm_output=output,
|
||||
messages=messages,
|
||||
prompt_params=prompt_params,
|
||||
num_reasks=self.num_reasks,
|
||||
metadata=self.metadata,
|
||||
full_schema_reask=self.full_schema_reask,
|
||||
)
|
||||
outputs = Outputs()
|
||||
iteration = Iteration(
|
||||
callId=call_log.id, index=index, inputs=inputs, outputs=outputs
|
||||
)
|
||||
set_scope(str(id(iteration)))
|
||||
call_log.iterations.push(iteration)
|
||||
|
||||
try:
|
||||
# Prepare: run pre-processing, and input validation.
|
||||
if output is not None:
|
||||
messages = None
|
||||
else:
|
||||
messages = self.prepare(
|
||||
call_log,
|
||||
messages=messages,
|
||||
prompt_params=prompt_params,
|
||||
api=api,
|
||||
attempt_number=index,
|
||||
)
|
||||
|
||||
iteration.inputs.messages = messages
|
||||
|
||||
# Call: run the API.
|
||||
llm_response = self.call(messages, api, output)
|
||||
|
||||
iteration.outputs.llm_response_info = llm_response
|
||||
raw_output = llm_response.output
|
||||
|
||||
# Parse: parse the output.
|
||||
parsed_output, parsing_error = self.parse(raw_output, output_schema)
|
||||
if parsing_error or isinstance(parsed_output, ReAsk):
|
||||
iteration.outputs.exception = parsing_error # type: ignore
|
||||
iteration.outputs.error = str(parsing_error)
|
||||
iteration.outputs.reasks.append(parsed_output) # type: ignore
|
||||
else:
|
||||
iteration.outputs.parsed_output = parsed_output
|
||||
|
||||
# Validate: run output validation.
|
||||
if parsing_error and isinstance(parsed_output, NonParseableReAsk):
|
||||
reasks, _ = self.introspect(parsed_output)
|
||||
else:
|
||||
# Validate: run output validation.
|
||||
validated_output = self.validate(
|
||||
iteration, index, parsed_output, output_schema
|
||||
)
|
||||
iteration.outputs.validation_response = validated_output
|
||||
|
||||
# Introspect: inspect validated output for reasks.
|
||||
reasks, valid_output = self.introspect(validated_output)
|
||||
iteration.outputs.guarded_output = valid_output
|
||||
|
||||
iteration.outputs.reasks = list(reasks)
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
iteration.outputs.error = error_message
|
||||
iteration.outputs.exception = e
|
||||
raise e
|
||||
return iteration
|
||||
|
||||
@trace(name="/input_validation", origin="Runner.validate_messages")
|
||||
def validate_messages(
|
||||
self, call_log: Call, messages: MessageHistory, attempt_number: int
|
||||
) -> None:
|
||||
for msg in messages:
|
||||
content = (
|
||||
msg["content"].source
|
||||
if isinstance(msg["content"], Prompt)
|
||||
else msg["content"]
|
||||
)
|
||||
inputs = Inputs(
|
||||
llm_output=content,
|
||||
)
|
||||
iteration = Iteration(
|
||||
callId=call_log.id, index=attempt_number, inputs=inputs
|
||||
)
|
||||
call_log.iterations.insert(0, iteration)
|
||||
value, _metadata = validator_service.validate(
|
||||
value=content,
|
||||
metadata=self.metadata,
|
||||
validator_map=self.validation_map,
|
||||
iteration=iteration,
|
||||
disable_tracer=self._disable_tracer,
|
||||
path="messages",
|
||||
)
|
||||
|
||||
validated_msg = validator_service.post_process_validation(
|
||||
value, attempt_number, iteration, OutputTypes.STRING
|
||||
)
|
||||
|
||||
iteration.outputs.validation_response = validated_msg
|
||||
|
||||
if isinstance(validated_msg, ReAsk):
|
||||
raise ValidationError(f"Messages validation failed: {validated_msg}")
|
||||
elif not validated_msg or iteration.status == fail_status:
|
||||
raise ValidationError("Messages validation failed")
|
||||
|
||||
msg["content"] = cast(str, validated_msg)
|
||||
|
||||
return messages # type: ignore
|
||||
|
||||
def prepare_messages(
|
||||
self,
|
||||
call_log: Call,
|
||||
messages: MessageHistory,
|
||||
prompt_params: Dict,
|
||||
attempt_number: int,
|
||||
) -> MessageHistory:
|
||||
formatted_messages: MessageHistory = []
|
||||
# Format any variables in the message history with the prompt params.
|
||||
for msg in messages:
|
||||
msg_copy = copy.deepcopy(msg)
|
||||
if attempt_number == 0:
|
||||
msg_copy["content"] = msg_copy["content"].format(**prompt_params)
|
||||
formatted_messages.append(msg_copy)
|
||||
|
||||
# validate messages
|
||||
if "messages" in self.validation_map:
|
||||
self.validate_messages(call_log, formatted_messages, attempt_number)
|
||||
|
||||
return formatted_messages
|
||||
|
||||
@trace(name="/input_validation", origin="Runner.validate_prompt")
|
||||
def validate_prompt(self, call_log: Call, prompt: Prompt, attempt_number: int):
|
||||
inputs = Inputs(
|
||||
llm_output=prompt.source,
|
||||
)
|
||||
iteration = Iteration(callId=call_log.id, index=attempt_number, inputs=inputs)
|
||||
call_log.iterations.insert(0, iteration)
|
||||
value, _metadata = validator_service.validate(
|
||||
value=prompt.source,
|
||||
metadata=self.metadata,
|
||||
validator_map=self.validation_map,
|
||||
iteration=iteration,
|
||||
disable_tracer=self._disable_tracer,
|
||||
path="prompt",
|
||||
)
|
||||
|
||||
validated_prompt = validator_service.post_process_validation(
|
||||
value, attempt_number, iteration, OutputTypes.STRING
|
||||
)
|
||||
|
||||
iteration.outputs.validation_response = validated_prompt
|
||||
|
||||
if isinstance(validated_prompt, ReAsk):
|
||||
raise ValidationError(f"Prompt validation failed: {validated_prompt}")
|
||||
elif not validated_prompt or iteration.status == fail_status:
|
||||
raise ValidationError("Prompt validation failed")
|
||||
return Prompt(cast(str, validated_prompt))
|
||||
|
||||
@trace(name="/input_prep", origin="Runner.prepare")
|
||||
def prepare(
|
||||
self,
|
||||
call_log: Call,
|
||||
attempt_number: int,
|
||||
*,
|
||||
messages: Optional[MessageHistory],
|
||||
prompt_params: Optional[Dict] = None,
|
||||
api: Optional[Union[PromptCallableBase, AsyncPromptCallableBase]],
|
||||
) -> Optional[MessageHistory]:
|
||||
"""Prepare by running pre-processing and input validation.
|
||||
|
||||
Returns:
|
||||
The message history.
|
||||
"""
|
||||
prompt_params = prompt_params or {}
|
||||
if api is None:
|
||||
raise UserFacingException(ValueError("API must be provided."))
|
||||
|
||||
if messages:
|
||||
messages = self.prepare_messages(
|
||||
call_log, messages, prompt_params, attempt_number
|
||||
)
|
||||
|
||||
return messages
|
||||
|
||||
@trace(name="/llm_call", origin="Runner.call")
|
||||
@trace_call
|
||||
def call(
|
||||
self,
|
||||
messages: Optional[MessageHistory],
|
||||
api: Optional[PromptCallableBase],
|
||||
output: Optional[str] = None,
|
||||
) -> LLMResponse:
|
||||
"""Run a step.
|
||||
|
||||
1. Query the LLM API,
|
||||
2. Convert the response string to a dict,
|
||||
3. Log the output
|
||||
"""
|
||||
|
||||
# If the API supports a base model, pass it in.
|
||||
api_fn = api
|
||||
if api is not None:
|
||||
supports_base_model = getattr(api, "supports_base_model", False)
|
||||
if supports_base_model:
|
||||
api_fn = partial(api, base_model=self.base_model)
|
||||
|
||||
if output is not None:
|
||||
llm_response = LLMResponse(output=output)
|
||||
elif api_fn is None:
|
||||
raise ValueError("API or output must be provided.")
|
||||
elif messages:
|
||||
llm_response = api_fn(messages=messages_source(messages))
|
||||
else:
|
||||
llm_response = api_fn()
|
||||
|
||||
return llm_response
|
||||
|
||||
def parse(self, output: str, output_schema: Dict[str, Any], **kwargs):
|
||||
parsed_output, error = parse_llm_output(output, self.output_type, **kwargs)
|
||||
if parsed_output and not error and not isinstance(parsed_output, ReAsk):
|
||||
parsed_output = prune_extra_keys(parsed_output, output_schema)
|
||||
parsed_output = coerce_types(parsed_output, output_schema)
|
||||
return parsed_output, error
|
||||
|
||||
@trace(name="/validation", origin="Runner.validate")
|
||||
def validate(
|
||||
self,
|
||||
iteration: Iteration,
|
||||
attempt_number: int,
|
||||
parsed_output: Any,
|
||||
output_schema: Dict[str, Any],
|
||||
stream: Optional[bool] = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""Validate the output."""
|
||||
# Break early if empty
|
||||
if parsed_output is None:
|
||||
return None
|
||||
|
||||
skeleton_reask = schema_validation(parsed_output, output_schema, **kwargs)
|
||||
if skeleton_reask:
|
||||
return skeleton_reask
|
||||
|
||||
if self.output_type != OutputTypes.STRING:
|
||||
stream = None
|
||||
|
||||
validated_output, metadata = validator_service.validate(
|
||||
value=parsed_output,
|
||||
metadata=self.metadata,
|
||||
validator_map=self.validation_map,
|
||||
iteration=iteration,
|
||||
disable_tracer=self._disable_tracer,
|
||||
path="$",
|
||||
stream=stream,
|
||||
**kwargs,
|
||||
)
|
||||
self.metadata.update(metadata)
|
||||
validated_output = validator_service.post_process_validation(
|
||||
validated_output, attempt_number, iteration, self.output_type
|
||||
)
|
||||
|
||||
return validated_output
|
||||
|
||||
def introspect(
|
||||
self,
|
||||
validated_output: Any,
|
||||
) -> Tuple[Sequence[ReAsk], Optional[Union[str, Dict, List]]]:
|
||||
"""Introspect the validated output."""
|
||||
if validated_output is None:
|
||||
return [], None
|
||||
reasks, valid_output = introspect(validated_output)
|
||||
|
||||
return reasks, valid_output
|
||||
|
||||
def do_loop(self, attempt_number: int, reasks: Sequence[ReAsk]) -> bool:
|
||||
"""Determine if we should loop again."""
|
||||
if reasks and attempt_number < self.num_reasks:
|
||||
return True
|
||||
return False
|
||||
|
||||
def prepare_to_loop(
|
||||
self,
|
||||
reasks: Sequence[ReAsk],
|
||||
output_schema: Dict[str, Any],
|
||||
*,
|
||||
parsed_output: Optional[Union[str, List, Dict, ReAsk]] = None,
|
||||
validated_output: Optional[Union[str, List, Dict, ReAsk]] = None,
|
||||
prompt_params: Optional[Dict] = None,
|
||||
) -> Tuple[
|
||||
Dict[str, Any],
|
||||
Optional[Union[List[Dict], Messages]],
|
||||
]:
|
||||
"""Prepare to loop again."""
|
||||
prompt_params = prompt_params or {}
|
||||
output_schema, messages = get_reask_setup(
|
||||
output_type=self.output_type,
|
||||
output_schema=output_schema,
|
||||
validation_map=self.validation_map,
|
||||
reasks=reasks,
|
||||
parsing_response=parsed_output,
|
||||
validation_response=validated_output,
|
||||
use_full_schema=self.full_schema_reask,
|
||||
prompt_params=prompt_params,
|
||||
exec_options=self.exec_options,
|
||||
)
|
||||
|
||||
return output_schema, messages
|
||||
335
참고/guardrails-main/guardrails/run/stream_runner.py
Normal file
335
참고/guardrails-main/guardrails/run/stream_runner.py
Normal file
@@ -0,0 +1,335 @@
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, cast
|
||||
|
||||
from guardrails import validator_service
|
||||
from guardrails.classes.history import Call, Inputs, Iteration, Outputs
|
||||
from guardrails.classes.output_type import OT, OutputTypes
|
||||
from guardrails.classes.validation_outcome import ValidationOutcome
|
||||
from guardrails.llm_providers import (
|
||||
PromptCallableBase,
|
||||
)
|
||||
from guardrails.run.runner import Runner
|
||||
from guardrails.hub_telemetry.hub_tracing import trace_stream
|
||||
from guardrails.utils.parsing_utils import (
|
||||
coerce_types,
|
||||
parse_llm_output,
|
||||
prune_extra_keys,
|
||||
)
|
||||
from guardrails.actions.reask import ReAsk, SkeletonReAsk
|
||||
from guardrails.constants import pass_status
|
||||
from guardrails.telemetry import trace_stream_step
|
||||
from guardrails.utils.safe_get import safe_get
|
||||
|
||||
|
||||
class StreamRunner(Runner):
|
||||
"""Runner class that calls a streaming LLM API with a prompt.
|
||||
|
||||
This class performs output validation when the output is a stream of
|
||||
chunks. Inherits from Runner class, as overall structure remains
|
||||
similar.
|
||||
"""
|
||||
|
||||
@trace_stream(name="/reasks", origin="StreamRunner.__call__")
|
||||
def __call__(
|
||||
self, call_log: Call, prompt_params: Optional[Dict] = {}
|
||||
) -> Iterator[ValidationOutcome[OT]]:
|
||||
"""Execute the StreamRunner.
|
||||
|
||||
Args:
|
||||
prompt_params: Parameters to pass to the prompt in order to
|
||||
generate the prompt string.
|
||||
|
||||
Returns:
|
||||
The Call log for this run.
|
||||
"""
|
||||
|
||||
prompt_params = prompt_params or {}
|
||||
|
||||
(
|
||||
messages,
|
||||
output_schema,
|
||||
) = (
|
||||
self.messages,
|
||||
self.output_schema,
|
||||
)
|
||||
|
||||
return self.step(
|
||||
index=0,
|
||||
api=self.api,
|
||||
messages=messages,
|
||||
prompt_params=prompt_params,
|
||||
output_schema=output_schema,
|
||||
output=self.output,
|
||||
call_log=call_log,
|
||||
)
|
||||
|
||||
@trace_stream(name="/step", origin="StreamRunner.step")
|
||||
@trace_stream_step
|
||||
def step(
|
||||
self,
|
||||
index: int,
|
||||
api: Optional[PromptCallableBase],
|
||||
messages: Optional[List[Dict]],
|
||||
prompt_params: Dict,
|
||||
output_schema: Dict[str, Any],
|
||||
call_log: Call,
|
||||
output: Optional[str] = None,
|
||||
) -> Iterator[ValidationOutcome[OT]]:
|
||||
"""Run a full step."""
|
||||
inputs = Inputs(
|
||||
llm_api=api,
|
||||
llm_output=output,
|
||||
messages=messages,
|
||||
prompt_params=prompt_params,
|
||||
num_reasks=self.num_reasks,
|
||||
metadata=self.metadata,
|
||||
full_schema_reask=self.full_schema_reask,
|
||||
stream=True,
|
||||
)
|
||||
outputs = Outputs()
|
||||
iteration = Iteration(
|
||||
callId=call_log.id, index=index, inputs=inputs, outputs=outputs
|
||||
)
|
||||
call_log.iterations.push(iteration)
|
||||
|
||||
# Prepare: run pre-processing, and input validation.
|
||||
if output is not None:
|
||||
messages = None
|
||||
else:
|
||||
messages = self.prepare(
|
||||
call_log,
|
||||
index,
|
||||
messages=messages,
|
||||
prompt_params=prompt_params,
|
||||
api=api,
|
||||
)
|
||||
|
||||
iteration.inputs.messages = messages
|
||||
|
||||
# Call: run the API that returns a generator wrapped in LLMResponse
|
||||
llm_response = self.call(messages, api, output)
|
||||
|
||||
iteration.outputs.llm_response_info = llm_response
|
||||
|
||||
# Get the stream (generator) from the LLMResponse
|
||||
stream = llm_response.stream_output
|
||||
if stream is None:
|
||||
raise ValueError(
|
||||
"No stream was returned from the API. Please check that "
|
||||
"the API is returning a generator."
|
||||
)
|
||||
|
||||
parsed_fragment, validated_fragment, valid_op = "", None, None
|
||||
verified = set()
|
||||
validation_response = ""
|
||||
fragment = ""
|
||||
# Loop over the stream
|
||||
# and construct "fragments" of concatenated chunks
|
||||
# for now, handle string and json schema differently
|
||||
if self.output_type == OutputTypes.STRING:
|
||||
|
||||
def prepare_chunk_generator(stream) -> Iterator[Tuple[Any, bool]]:
|
||||
for chunk in stream:
|
||||
chunk_text = self.get_chunk_text(chunk, api)
|
||||
nonlocal fragment
|
||||
fragment += chunk_text
|
||||
finished = self.is_last_chunk(chunk, api)
|
||||
# 2. Parse the chunk
|
||||
parsed_chunk, move_to_next = self.parse(
|
||||
chunk_text, output_schema, verified=verified
|
||||
)
|
||||
nonlocal parsed_fragment
|
||||
# ignore types because output schema guarantees a string
|
||||
parsed_fragment += parsed_chunk # type: ignore
|
||||
if move_to_next:
|
||||
# Continue to next chunk
|
||||
continue
|
||||
yield parsed_chunk, finished
|
||||
|
||||
prepped_stream = prepare_chunk_generator(stream)
|
||||
gen = validator_service.validate_stream(
|
||||
prepped_stream,
|
||||
self.metadata,
|
||||
self.validation_map,
|
||||
iteration,
|
||||
self._disable_tracer,
|
||||
"$",
|
||||
validate_subschema=True,
|
||||
)
|
||||
|
||||
for res in gen:
|
||||
chunk = res.chunk
|
||||
original_text = res.original_text
|
||||
if isinstance(chunk, SkeletonReAsk):
|
||||
raise ValueError(
|
||||
"Received fragment schema is an invalid sub-schema "
|
||||
"of the expected output JSON schema."
|
||||
)
|
||||
|
||||
# 4. Introspect: inspect the validated fragment for reasks
|
||||
reasks, valid_op = self.introspect(chunk)
|
||||
if reasks:
|
||||
raise ValueError(
|
||||
"Reasks are not yet supported with streaming. Please "
|
||||
"remove reasks from schema or disable streaming."
|
||||
)
|
||||
# 5. Convert validated fragment to a pretty JSON string
|
||||
validation_response += cast(str, chunk)
|
||||
passed = call_log.status == pass_status
|
||||
yield ValidationOutcome(
|
||||
call_id=call_log.id, # type: ignore
|
||||
# The chunk or the whole output?
|
||||
rawLlmOutput=original_text,
|
||||
validatedOutput=chunk,
|
||||
validationPassed=passed,
|
||||
)
|
||||
|
||||
# handle non string schema
|
||||
else:
|
||||
for chunk in stream:
|
||||
# 1. Get the text from the chunk and append to fragment
|
||||
chunk_text = self.get_chunk_text(chunk, api)
|
||||
fragment += chunk_text
|
||||
|
||||
# 2. Parse the fragment
|
||||
parsed_fragment, move_to_next = self.parse(
|
||||
fragment, output_schema, verified=verified
|
||||
)
|
||||
if move_to_next:
|
||||
# Continue to next chunk
|
||||
continue
|
||||
|
||||
# 3. Run output validation
|
||||
validated_fragment = self.validate(
|
||||
iteration,
|
||||
index,
|
||||
parsed_fragment,
|
||||
output_schema,
|
||||
validate_subschema=True,
|
||||
)
|
||||
if isinstance(validated_fragment, SkeletonReAsk):
|
||||
raise ValueError(
|
||||
"Received fragment schema is an invalid sub-schema "
|
||||
"of the expected output JSON schema."
|
||||
)
|
||||
|
||||
# 4. Introspect: inspect the validated fragment for reasks
|
||||
reasks, valid_op = self.introspect(validated_fragment)
|
||||
if reasks:
|
||||
raise ValueError(
|
||||
"Reasks are not yet supported with streaming. Please "
|
||||
"remove reasks from schema or disable streaming."
|
||||
)
|
||||
|
||||
if self.output_type == OutputTypes.LIST:
|
||||
validation_response = cast(list, validated_fragment)
|
||||
else:
|
||||
validation_response = cast(dict, validated_fragment)
|
||||
# 5. Convert validated fragment to a pretty JSON string
|
||||
yield ValidationOutcome(
|
||||
callId=call_log.id,
|
||||
rawLlmOutput=fragment,
|
||||
validatedOutput=validated_fragment,
|
||||
validationPassed=validated_fragment is not None,
|
||||
)
|
||||
|
||||
# # Finally, add to logs
|
||||
iteration.outputs.raw_output = fragment
|
||||
iteration.outputs.parsed_output = parsed_fragment or fragment # type: ignore
|
||||
iteration.outputs.validation_response = validation_response
|
||||
iteration.outputs.guarded_output = valid_op
|
||||
|
||||
def is_last_chunk(self, chunk: Any, api: Union[PromptCallableBase, None]) -> bool:
|
||||
"""Detect if chunk is final chunk."""
|
||||
try:
|
||||
if (
|
||||
not chunk.choices or len(chunk.choices) == 0
|
||||
) and chunk.usage is not None:
|
||||
# This is the last extra chunk for usage statistics
|
||||
return True
|
||||
finished = chunk.choices[0].finish_reason
|
||||
return finished is not None
|
||||
except (AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
def get_chunk_text(self, chunk: Any, api: Union[PromptCallableBase, None]) -> str:
|
||||
"""Get the text from a chunk.
|
||||
|
||||
chunk is assumed to be an Iterator of either string or
|
||||
ChatCompletionChunk
|
||||
|
||||
These types are not properly enforced upstream so we must use
|
||||
reflection
|
||||
"""
|
||||
# Safeguard against None
|
||||
# which can happen when the user provides
|
||||
# custom LLM wrappers
|
||||
if not chunk:
|
||||
return ""
|
||||
elif isinstance(chunk, str):
|
||||
# If chunk is a string, return it
|
||||
return chunk
|
||||
elif hasattr(chunk, "choices") and hasattr(chunk.choices, "__iter__"):
|
||||
# If chunk is a ChatCompletionChunk, return the text
|
||||
# from the first choice
|
||||
chunk_text = ""
|
||||
first_choice = safe_get(chunk.choices, 0)
|
||||
if not first_choice:
|
||||
return chunk_text
|
||||
|
||||
if hasattr(first_choice, "delta") and hasattr(
|
||||
first_choice.delta, "content"
|
||||
):
|
||||
chunk_text = first_choice.delta.content
|
||||
elif hasattr(first_choice, "text"):
|
||||
chunk_text = first_choice.text
|
||||
else:
|
||||
# If chunk is not a string or ChatCompletionChunk, raise an error
|
||||
raise ValueError(
|
||||
"chunk.choices[0] does not have "
|
||||
"delta.content or text. "
|
||||
"Non-OpenAI compliant callables must return "
|
||||
"a generator of strings."
|
||||
)
|
||||
|
||||
if not chunk_text:
|
||||
# If chunk_text is empty, return an empty string
|
||||
return ""
|
||||
elif not isinstance(chunk_text, str):
|
||||
# If chunk_text is not a string, raise an error
|
||||
raise ValueError(
|
||||
"Chunk text is not a string. "
|
||||
"Non-OpenAI compliant callables must return "
|
||||
"a generator of strings."
|
||||
)
|
||||
return chunk_text
|
||||
else:
|
||||
# If chunk is not a string or ChatCompletionChunk, raise an error
|
||||
raise ValueError(
|
||||
"Chunk is not a string or ChatCompletionChunk. "
|
||||
"Non-OpenAI compliant callables must return "
|
||||
"a generator of strings."
|
||||
)
|
||||
|
||||
def parse(
|
||||
self, output: str, output_schema: Dict[str, Any], *, verified: set, **kwargs
|
||||
):
|
||||
"""Parse the output."""
|
||||
parsed_output, error = parse_llm_output(
|
||||
output, self.output_type, stream=True, verified=verified
|
||||
)
|
||||
|
||||
if parsed_output and not error and not isinstance(parsed_output, ReAsk):
|
||||
parsed_output = prune_extra_keys(parsed_output, output_schema)
|
||||
parsed_output = coerce_types(parsed_output, output_schema)
|
||||
|
||||
# Error can be either of
|
||||
# (True/False/None/ValueError/string representing error)
|
||||
if error:
|
||||
# If parsing error is a string,
|
||||
# it is an error from output_schema.parse_fragment()
|
||||
if isinstance(error, str):
|
||||
raise ValueError("Unable to parse output: " + error)
|
||||
# Else if either of
|
||||
# (None/True/False/ValueError), return parsed_output and error
|
||||
|
||||
return parsed_output, error
|
||||
90
참고/guardrails-main/guardrails/run/utils.py
Normal file
90
참고/guardrails-main/guardrails/run/utils.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import copy
|
||||
from string import Template
|
||||
from typing import Dict, cast, Optional, Tuple
|
||||
|
||||
from guardrails.classes.output_type import OutputTypes
|
||||
from guardrails.llm_providers import (
|
||||
LiteLLMCallable,
|
||||
AsyncLiteLLMCallable,
|
||||
PromptCallableBase,
|
||||
)
|
||||
from guardrails.prompt.prompt import Prompt
|
||||
from guardrails.types.inputs import MessageHistory
|
||||
from guardrails.prompt.instructions import Instructions
|
||||
|
||||
|
||||
def messages_source(messages: MessageHistory) -> MessageHistory:
|
||||
messages_copy = []
|
||||
for msg in messages:
|
||||
msg_copy = copy.deepcopy(msg)
|
||||
content = (
|
||||
msg["content"].source
|
||||
if isinstance(msg["content"], Prompt)
|
||||
or isinstance(msg["content"], Instructions)
|
||||
else msg["content"]
|
||||
)
|
||||
msg_copy["content"] = content
|
||||
messages_copy.append(cast(Dict[str, str], msg_copy))
|
||||
return messages_copy
|
||||
|
||||
|
||||
def preprocess_prompt_for_string_output(
|
||||
prompt_callable: PromptCallableBase,
|
||||
instructions: Optional[Instructions],
|
||||
prompt: Prompt,
|
||||
) -> Tuple[Optional[Instructions], Prompt]:
|
||||
if isinstance(prompt_callable, LiteLLMCallable) or isinstance(
|
||||
prompt_callable, AsyncLiteLLMCallable
|
||||
):
|
||||
prompt.source += "\n\nString Output:\n\n"
|
||||
if (
|
||||
isinstance(prompt_callable, LiteLLMCallable)
|
||||
or isinstance(prompt_callable, AsyncLiteLLMCallable)
|
||||
) and not instructions:
|
||||
instructions = Instructions(
|
||||
"You are a helpful assistant, expressing yourself through a string."
|
||||
)
|
||||
|
||||
return instructions, prompt
|
||||
|
||||
|
||||
def preprocess_prompt_for_json_output(
|
||||
prompt_callable: PromptCallableBase,
|
||||
instructions: Optional[Instructions],
|
||||
prompt: Prompt,
|
||||
use_xml: bool,
|
||||
) -> Tuple[Optional[Instructions], Prompt]:
|
||||
if isinstance(prompt_callable, LiteLLMCallable) or isinstance(
|
||||
prompt_callable, AsyncLiteLLMCallable
|
||||
):
|
||||
prompt.source += "\n\nJson Output:\n\n"
|
||||
if (
|
||||
isinstance(prompt_callable, LiteLLMCallable)
|
||||
or isinstance(prompt_callable, AsyncLiteLLMCallable)
|
||||
) and not instructions:
|
||||
schema_type = "XML schemas" if use_xml else "JSON schema"
|
||||
instructions = Instructions(
|
||||
Template(
|
||||
"You are a helpful assistant, "
|
||||
"able to express yourself purely through JSON, "
|
||||
"strictly and precisely adhering to the provided ${schema_type}."
|
||||
).safe_substitute(schema_type=schema_type)
|
||||
)
|
||||
|
||||
return instructions, prompt
|
||||
|
||||
|
||||
def preprocess_prompt(
|
||||
prompt_callable: PromptCallableBase,
|
||||
instructions: Optional[Instructions],
|
||||
prompt: Prompt,
|
||||
output_type: OutputTypes,
|
||||
use_xml: bool,
|
||||
) -> Tuple[Optional[Instructions], Prompt]:
|
||||
if output_type == OutputTypes.STRING:
|
||||
return preprocess_prompt_for_string_output(
|
||||
prompt_callable, instructions, prompt
|
||||
)
|
||||
return preprocess_prompt_for_json_output(
|
||||
prompt_callable, instructions, prompt, use_xml
|
||||
)
|
||||
Reference in New Issue
Block a user