Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,37 @@ To learn more about TypeChat, visit the
[documentation](https://microsoft.github.io/TypeChat/docs/python/basic-usage/)
which includes more information on TypeChat and how to get started.

## Accessing failed output

`Failure` retains its existing `message` and can also carry an unvalidated `value: object` and
`response_text: str | None`. Validators return the rejected input in `value`; translators also retain
the original model text, including when it is not valid JSON:

```python
from typechat import Failure

result = await translator.translate(request)
if isinstance(result, Failure):
if result.has_value:
rejected = result.value
# Validate or repair this value before treating it as the target type.
original_response = result.response_text
```

Check `has_value` before using `value`. It distinguishes unavailable data from a rejected `None`,
`False`, `0`, or empty string. An unavailable value is represented by an internal missing-value marker,
not `None`. Custom validators can supply their own payloads using
`Failure(message, value=rejected, response_text=text)`, and the translator preserves those payloads.
`Failure(message)` remains valid, and payload fields are omitted from its representation.
Use `isinstance` with `Success` or `Failure` to distinguish outcomes, not the presence of `value`.

On final failure, automatic repair retains the latest rejected candidate and available response text,
not a full attempt history. If a repair response cannot be parsed, `value` can come from an earlier
attempt than `response_text`. An initial network failure has no candidate value. Successful results,
error messages, and repair limits are unchanged, including Python's existing repair of malformed JSON.

Rejected values and response text may contain sensitive output. Do not log or serialize them blindly.

## Contributing

This project welcomes contributions and suggestions. Most contributions require you to agree to a
Expand Down
21 changes: 19 additions & 2 deletions python/src/typechat/_internal/result.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import Enum
from typing_extensions import Generic, TypeAlias, TypeVar

T = TypeVar("T", covariant=True)

class _MissingValue(Enum):
MISSING = 0

@dataclass
class Success(Generic[T]):
"An object representing a successful operation with a result of type `T`."
Expand All @@ -11,8 +15,21 @@ class Success(Generic[T]):

@dataclass
class Failure:
"An object representing an operation that failed for the reason given in `message`."
"""
An operation that failed for the reason given in `message`.

`value` is unvalidated and is available only when `has_value` is true. `response_text` contains
the latest model response, when available. If a repair response couldn't be parsed, `value`
may refer to a rejected candidate from an earlier attempt.
"""
message: str
value: object = field(default=_MissingValue.MISSING, kw_only=True, repr=False)
response_text: str | None = field(default=None, kw_only=True, repr=False)

@property
def has_value(self) -> bool:
"""Distinguishes missing data from an explicitly supplied value, including `None`."""
return self.value is not _MissingValue.MISSING


"""
Expand Down
38 changes: 31 additions & 7 deletions python/src/typechat/_internal/translator.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from copy import copy
from typing_extensions import Generic, TypeVar

import pydantic_core
Expand Down Expand Up @@ -55,6 +56,8 @@ async def translate(self, input: str, *, prompt_preamble: str | list[PromptSecti
the language model fails to validate, repair attempts will be made up until `_max_repair_attempts`.
The prompt for the subsequent attempts will include the diagnostics produced for the prior attempt.
This often helps produce a valid instance.
A failure retains the latest rejected value and available model response text. These may come
from different attempts if a repair response couldn't be parsed.

Args:
input: A natural language request.
Expand All @@ -72,33 +75,54 @@ async def translate(self, input: str, *, prompt_preamble: str | list[PromptSecti
messages.append({"role": "user", "content": self._create_request_prompt(input)})

num_repairs_attempted = 0
last_failure: Failure | None = None
while True:
completion_response = await self.model.complete(messages)
if isinstance(completion_response, Failure):
return completion_response
if last_failure is None:
return completion_response
failure = copy(completion_response)
if not failure.has_value:
failure.value = last_failure.value
if failure.response_text is None:
failure.response_text = last_failure.response_text
return failure

text_response = completion_response.value
first_curly = text_response.find("{")
last_curly = text_response.rfind("}") + 1
error_message: str
failure: Failure
if 0 <= first_curly < last_curly:
trimmed_response = text_response[first_curly:last_curly]
try:
parsed_response = pydantic_core.from_json(trimmed_response, allow_inf_nan=False, cache_strings=False)
except ValueError as e:
error_message = f"Error: {e}\n\nAttempted to parse:\n\n{trimmed_response}"
failure = Failure(
f"Error: {e}\n\nAttempted to parse:\n\n{trimmed_response}",
response_text=text_response,
)
else:
result = self.validator.validate_object(parsed_response)
if isinstance(result, Success):
return result
error_message = result.message
failure = copy(result)
if failure.response_text is None:
failure.response_text = text_response
if not failure.has_value:
failure.value = parsed_response
else:
error_message = f"Response did not contain any text resembling JSON.\nResponse was\n\n{text_response}"
failure = Failure(
f"Response did not contain any text resembling JSON.\nResponse was\n\n{text_response}",
response_text=text_response,
)
if not failure.has_value and last_failure is not None:
failure.value = last_failure.value
last_failure = failure
if num_repairs_attempted >= self._max_repair_attempts:
return Failure(error_message)
return failure
num_repairs_attempted += 1
messages.append({"role": "assistant", "content": text_response})
messages.append({"role": "user", "content": self._create_repair_prompt(error_message)})
messages.append({"role": "user", "content": self._create_repair_prompt(failure.message)})

def _create_request_prompt(self, intent: str) -> str:
prompt = f"""
Expand Down
8 changes: 4 additions & 4 deletions python/src/typechat/_internal/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def validate_object(self, obj: object) -> Result[T]:
Validates the given Python object according to the associated schema type.

Returns a `Success[T]` object containing the object if validation was successful.
Otherwise, returns a `Failure` object with a `message` property describing the error.
Otherwise, returns a `Failure` with an error message and the rejected object in `value`.
"""
try:
# TODO: Switch to `validate_python` when validation modes are exposed.
Expand All @@ -42,10 +42,10 @@ def validate_object(self, obj: object) -> Result[T]:
typed_dict = self._adapted_type.validate_json(json_str, strict=True)
return Success(typed_dict)
except pydantic.ValidationError as validation_error:
return _handle_error(validation_error)
return _handle_error(validation_error, obj)


def _handle_error(validation_error: pydantic.ValidationError) -> Failure:
def _handle_error(validation_error: pydantic.ValidationError, value: object) -> Failure:
error_strings: list[str] = []
for error in validation_error.errors(include_url=False):
error_string = ""
Expand All @@ -64,4 +64,4 @@ def _handle_error(validation_error: pydantic.ValidationError) -> Failure:
failure_message = ""
failure_message += "\n".join(error_strings)

return Failure(failure_message)
return Failure(failure_message, value=value)
Loading
Loading