Skip to content
Open
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
4 changes: 4 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ Install TypeChat:
pip install typechat
```

To use TypeChat in an existing backend without an interactive chat loop, follow the
[non-interactive Python quickstart](../site/src/docs/python/non-interactive.md).
It includes a [standalone sentiment example](./examples/sentiment/non_interactive.py) and an async function you can call from application code.

You can also develop TypeChat from source, which needs [Python >=3.11](https://www.python.org/downloads/),
[hatch](https://hatch.pypa.io/1.6/install/), and [Node.js >=20](https://nodejs.org/en/download):

Expand Down
10 changes: 10 additions & 0 deletions python/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,16 @@ AZURE_OPENAI_API_KEY=...

Examples can be found in the `examples` directory.

To classify one text input without starting an interactive session, run the
[non-interactive sentiment example](./sentiment/non_interactive.py) from the `python` directory:

```sh
python examples/sentiment/non_interactive.py "TypeChat is awesome!"
```

For an explanation of how to call the same async function from an existing backend, see the
[Python quickstart](../../site/src/docs/python/non-interactive.md).

To run an example interactively, type `python examples/<example_name>/demo.py` from the example's directory and enter requests when prompted. Type `quit` or `exit` to end the session. You can also open in VS Code the selected example's directory and press <kbd>F5</kbd> to launch it in debug mode.

Note that there are various sample "prose" files (e.g. `input.txt`) provided in each `src` directory that can give a sense of what you can run.
Expand Down
20 changes: 19 additions & 1 deletion python/examples/sentiment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,22 @@ For example, given the following input statement:
**Output**:
```
The sentiment is positive
```
```

# Non-interactive usage

The self-contained [`non_interactive.py`](./non_interactive.py) example accepts one text argument, prints the validated result as JSON, and exits without prompting for input.
After configuring the environment as described in the [examples README](../README.md), run it from the `python` directory:

```sh
python examples/sentiment/non_interactive.py "TypeChat is awesome!"
```

Example output:

```json
{"sentiment": "positive"}
```

The example exposes an async `classify_sentiment` function for use in an existing backend and surfaces translation failures as exceptions.
See the [Python quickstart](../../../site/src/docs/python/non-interactive.md) for setup, error handling, and application integration.
37 changes: 37 additions & 0 deletions python/examples/sentiment/non_interactive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import argparse
import asyncio
import json
import os
from dataclasses import asdict, dataclass
from typing import Literal

from dotenv import dotenv_values
from typechat import Failure, TypeChatJsonTranslator, TypeChatValidator, create_language_model


@dataclass
class Sentiment:
sentiment: Literal["negative", "neutral", "positive"]


async def classify_sentiment(text: str, translator: TypeChatJsonTranslator[Sentiment]) -> Sentiment:
result = await translator.translate(text)
if isinstance(result, Failure):
raise RuntimeError(result.message)
return result.value


async def main(text: str) -> None:
settings = {**dotenv_values(), **os.environ}
async with create_language_model(settings) as model:
validator = TypeChatValidator(Sentiment)
translator = TypeChatJsonTranslator(model, validator, Sentiment)
result = await classify_sentiment(text, translator)
print(json.dumps(asdict(result)))


if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Classify one text request without starting a chat REPL.")
parser.add_argument("text", help="The text to classify.")
args = parser.parse_args()
asyncio.run(main(args.text))
147 changes: 147 additions & 0 deletions python/tests/test_sentiment_non_interactive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import asyncio
import json
import runpy
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path

import pytest
from examples.sentiment import non_interactive
from typechat import Failure, PromptSection, Result, TypeChatJsonTranslator, TypeChatLanguageModel, TypeChatValidator

from .test_translator import FixedModel


@pytest.fixture(autouse=True)
def forbid_interactive_input(monkeypatch: pytest.MonkeyPatch) -> None:
def fail_if_prompted(_prompt: str = "") -> str:
pytest.fail("The non-interactive example must not prompt for input.")

monkeypatch.setattr("builtins.input", fail_if_prompted)


def create_translator(model: TypeChatLanguageModel) -> TypeChatJsonTranslator[non_interactive.Sentiment]:
validator = TypeChatValidator(non_interactive.Sentiment)
return TypeChatJsonTranslator(model, validator, non_interactive.Sentiment)


@pytest.mark.parametrize("sentiment", ["negative", "neutral", "positive"])
def test_classify_returns_validated_sentiment(sentiment: str) -> None:
model = FixedModel([json.dumps({"sentiment": sentiment})])
result = asyncio.run(non_interactive.classify_sentiment("Classify this text.", create_translator(model)))

assert isinstance(result, non_interactive.Sentiment)
assert result.sentiment == sentiment
request = model.conversation[0]["payload"]
assert isinstance(request, list)
assert "Classify this text." in request[0]["content"]


def test_classify_reuses_translator_for_independent_requests() -> None:
model = FixedModel(['{"sentiment": "positive"}', '{"sentiment": "negative"}'])
translator = create_translator(model)

async def classify_both() -> tuple[non_interactive.Sentiment, non_interactive.Sentiment]:
first = await non_interactive.classify_sentiment("I love this!", translator)
second = await non_interactive.classify_sentiment("I hate this!", translator)
return first, second

first, second = asyncio.run(classify_both())
assert first.sentiment == "positive"
assert second.sentiment == "negative"
first_request = model.conversation[0]["payload"]
second_request = model.conversation[2]["payload"]
assert isinstance(first_request, list)
assert isinstance(second_request, list)
assert len(first_request) == len(second_request) == 1
assert "I love this!" in first_request[0]["content"]
assert "I hate this!" in second_request[0]["content"]


def test_classify_repairs_invalid_sentiment() -> None:
model = FixedModel(['{"sentiment": "mixed"}', '{"sentiment": "positive"}'])
result = asyncio.run(non_interactive.classify_sentiment("TypeChat is awesome!", create_translator(model)))

assert result.sentiment == "positive"
assert len(model.conversation) == 4


def test_classify_surfaces_model_failure(monkeypatch: pytest.MonkeyPatch) -> None:
model = FixedModel([])

async def complete(_prompt: str | list[PromptSection]) -> Result[str]:
return Failure("Model unavailable")

monkeypatch.setattr(model, "complete", complete)
with pytest.raises(RuntimeError, match="Model unavailable"):
asyncio.run(non_interactive.classify_sentiment("Classify this text.", create_translator(model)))


@pytest.mark.parametrize(
("responses", "expected"),
[
(['{"sentiment": "positive"}'], {"sentiment": "positive"}),
(['{"sentiment": "mixed"}', '{"sentiment": "mixed"}'], None),
],
)
def test_main_closes_model_and_reports_result(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
responses: list[str],
expected: dict[str, str] | None,
) -> None:
model = FixedModel(responses)
closed = False

@asynccontextmanager
async def create_model(settings: dict[str, str | None]) -> AsyncIterator[FixedModel]:
nonlocal closed
assert settings["TYPECHAT_NON_INTERACTIVE_TEST"] == "process"
assert settings["TYPECHAT_NON_INTERACTIVE_FILE_TEST"] == "file"
try:
yield model
finally:
closed = True

def dotenv_values() -> dict[str, str | None]:
return {
"TYPECHAT_NON_INTERACTIVE_TEST": "file",
"TYPECHAT_NON_INTERACTIVE_FILE_TEST": "file",
}

monkeypatch.setenv("TYPECHAT_NON_INTERACTIVE_TEST", "process")
monkeypatch.delenv("TYPECHAT_NON_INTERACTIVE_FILE_TEST", raising=False)
monkeypatch.setattr(non_interactive, "dotenv_values", dotenv_values)
monkeypatch.setattr(non_interactive, "create_language_model", create_model)

if expected is None:
with pytest.raises(RuntimeError, match="negative.*neutral.*positive"):
asyncio.run(non_interactive.main("TypeChat is awesome!"))
else:
asyncio.run(non_interactive.main("TypeChat is awesome!"))

assert closed
output = capsys.readouterr()
assert output.err == ""
if expected is None:
assert output.out == ""
else:
assert json.loads(output.out) == expected


def test_script_requires_text(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", [str(non_interactive.__file__)])
with pytest.raises(SystemExit) as error:
runpy.run_path(str(non_interactive.__file__), run_name="__main__")

assert error.value.code == 2
assert "the following arguments are required: text" in capsys.readouterr().err


def test_quickstart_matches_runnable_example() -> None:
root = Path(__file__).resolve().parents[2]
quickstart = root / "site" / "src" / "docs" / "python" / "non-interactive.md"
documented_code = quickstart.read_text(encoding="utf-8").split("```python\n", 1)[1].split("```", 1)[0]

assert documented_code == Path(non_interactive.__file__).read_text(encoding="utf-8")
4 changes: 3 additions & 1 deletion site/src/_data/docsTOC.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
{ "title": "Examples", "url": "/docs/examples/"},
{ "title": "Techniques", "url": "/docs/techniques/"},
{ "title": "FAQ", "url": "/docs/faq/"},
{ "title": "Basic Usage for TypeScript", "url": "/docs/typescript/basic-usage/"}
{ "title": "Basic Usage for TypeScript", "url": "/docs/typescript/basic-usage/"},
{ "title": "Basic Usage for Python", "url": "/docs/python/basic-usage/"},
{ "title": "Non-interactive Python Quickstart", "url": "/docs/python/non-interactive/"}
]
}
]
2 changes: 2 additions & 0 deletions site/src/docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ title: Examples

To see TypeChat in action, check out the examples found in [`/typescript/examples`](https://github.com/microsoft/TypeChat/tree/main/typescript/examples).

For Python, start with the [non-interactive quickstart](/docs/python/non-interactive/) to integrate TypeChat into an existing application, or explore the [Python examples](https://github.com/microsoft/TypeChat/tree/main/python/examples).

Each example shows how TypeChat handles natural language input, and maps to validated JSON as output. Most example inputs run on both GPT 3.5 and GPT 4.
We are working to reproduce outputs with other models.
Generally, models trained on both code and natural language text have high accuracy.
Expand Down
3 changes: 3 additions & 0 deletions site/src/docs/python/basic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ layout: doc-page
title: Basic Python Usage
---

For a runnable example without a chat loop and guidance on backend integration, start with the
[non-interactive Python quickstart](/docs/python/non-interactive/).

TypeChat is currently a small library, so we can get a solid understanding
just by going through the following example:

Expand Down
122 changes: 122 additions & 0 deletions site/src/docs/python/non-interactive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
---
layout: doc-page
title: Non-interactive Python Quickstart
---

TypeChat can be called directly from an existing Python application.
You do not need a chatbot, an interactive console, or the `process_requests` helper.
This example classifies a single text input and returns a validated Python object that your application can use.

## Install TypeChat

You need Python 3.11 or newer.
Install TypeChat and the optional helper used here to load a local `.env` file:

```sh
pip install typechat python-dotenv
```

## Configure a language model

Create a `.env` file in the directory where you will run the example:

```sh
OPENAI_API_KEY=your-api-key
OPENAI_MODEL=your-model-name
```

Replace the placeholders with your credentials and a model available to your account.
You can also set these variables in the process environment; the example gives those values precedence over the `.env` file.
For Azure OpenAI configuration, see the [Python examples configuration guide](https://github.com/microsoft/TypeChat/tree/main/python/examples#step-2-configure-environment-variables).
Do not commit your `.env` file or credentials.

## Make a single request

Save the following as `non_interactive.py`.
It is self-contained so you can copy the types and function into your own application.

```python
import argparse
import asyncio
import json
import os
from dataclasses import asdict, dataclass
from typing import Literal

from dotenv import dotenv_values
from typechat import Failure, TypeChatJsonTranslator, TypeChatValidator, create_language_model


@dataclass
class Sentiment:
sentiment: Literal["negative", "neutral", "positive"]


async def classify_sentiment(text: str, translator: TypeChatJsonTranslator[Sentiment]) -> Sentiment:
result = await translator.translate(text)
if isinstance(result, Failure):
raise RuntimeError(result.message)
return result.value


async def main(text: str) -> None:
settings = {**dotenv_values(), **os.environ}
async with create_language_model(settings) as model:
validator = TypeChatValidator(Sentiment)
translator = TypeChatJsonTranslator(model, validator, Sentiment)
result = await classify_sentiment(text, translator)
print(json.dumps(asdict(result)))


if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Classify one text request without starting a chat REPL.")
parser.add_argument("text", help="The text to classify.")
args = parser.parse_args()
asyncio.run(main(args.text))
```

Run it with one text argument:

```sh
python non_interactive.py "TypeChat is awesome!"
```

Example output:

```json
{"sentiment": "positive"}
```

The script processes that request and exits; it never prompts for more input.
The response is a `Sentiment` dataclass, not raw JSON from the language model.
`TypeChatValidator` checks that the model's response matches the type, and the translator can request a repair if validation fails.
Only a successful, validated value is serialized to JSON.

If translation returns a `Failure`, either because the model call fails or validation still fails after repair, `classify_sentiment` raises a `RuntimeError` with the failure message.
The script then exits with a nonzero status instead of printing a success-shaped response.
Configuration errors and unexpected exceptions also propagate to the caller.

## Use it in an existing backend

Create the model, validator, and translator during your application's startup, and keep them available to your async request handlers or background tasks.
Keep the model's async context open for the application's lifetime and close it during shutdown.
The `async with` block in the standalone example closes the HTTP client when the one-shot request finishes, including on failure.

For example, a framework-independent async handler can return an ordinary dictionary for your web framework to serialize:

```python
from non_interactive import Sentiment, classify_sentiment
from typechat import TypeChatJsonTranslator


async def handle_request(text: str, translator: TypeChatJsonTranslator[Sentiment]) -> dict[str, str]:
result = await classify_sentiment(text, translator)
return {"sentiment": result.sentiment}
```

Call `await handle_request(text, translator)` from your existing async application.
Do not call `asyncio.run` inside an already-running event loop; that function is only needed at a standalone script's entry point.
Handle or log translation failures using your application's normal error handling rather than treating a failure as a sentiment value.

The [runnable sentiment example](https://github.com/microsoft/TypeChat/blob/main/python/examples/sentiment/non_interactive.py) contains the same standalone code.
For an API walkthrough and interactive exploration, see [Basic Python Usage](/docs/python/basic-usage/) and the [other Python examples](https://github.com/microsoft/TypeChat/tree/main/python/examples).
Loading