diff --git a/python/README.md b/python/README.md index 11571366..192c34d9 100644 --- a/python/README.md +++ b/python/README.md @@ -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): diff --git a/python/examples/README.md b/python/examples/README.md index 4be5a544..015064f0 100644 --- a/python/examples/README.md +++ b/python/examples/README.md @@ -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//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 F5 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. diff --git a/python/examples/sentiment/README.md b/python/examples/sentiment/README.md index edff73a2..7051211c 100644 --- a/python/examples/sentiment/README.md +++ b/python/examples/sentiment/README.md @@ -18,4 +18,22 @@ For example, given the following input statement: **Output**: ``` The sentiment is positive -``` \ No newline at end of file +``` + +# 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. \ No newline at end of file diff --git a/python/examples/sentiment/non_interactive.py b/python/examples/sentiment/non_interactive.py new file mode 100644 index 00000000..253daf32 --- /dev/null +++ b/python/examples/sentiment/non_interactive.py @@ -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)) diff --git a/python/tests/test_sentiment_non_interactive.py b/python/tests/test_sentiment_non_interactive.py new file mode 100644 index 00000000..017de78a --- /dev/null +++ b/python/tests/test_sentiment_non_interactive.py @@ -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") diff --git a/site/src/_data/docsTOC.json b/site/src/_data/docsTOC.json index b517d40b..3e0f31fb 100644 --- a/site/src/_data/docsTOC.json +++ b/site/src/_data/docsTOC.json @@ -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/"} ] } ] diff --git a/site/src/docs/examples.md b/site/src/docs/examples.md index 9fc0ae07..1eb22a0f 100644 --- a/site/src/docs/examples.md +++ b/site/src/docs/examples.md @@ -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. diff --git a/site/src/docs/python/basic-usage.md b/site/src/docs/python/basic-usage.md index de556fdf..12b4e468 100644 --- a/site/src/docs/python/basic-usage.md +++ b/site/src/docs/python/basic-usage.md @@ -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: diff --git a/site/src/docs/python/non-interactive.md b/site/src/docs/python/non-interactive.md new file mode 100644 index 00000000..49a6b15f --- /dev/null +++ b/site/src/docs/python/non-interactive.md @@ -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).