Metadata-Version: 2.4
Name: wassitai
Version: 0.5.0
Summary: Universal structured-generation & LLM-output parsing framework
Author-email: Abdelaziz Kella <kaa.kella.abdelaziz@gmail.com>, Abdelaziz Kella <a.kella@univ-chlef.dz>
License: MIT
Project-URL: Homepage, https://github.com/kaaaziz/wassitai
Project-URL: Repository, https://github.com/kaaaziz/wassitai
Project-URL: Issues, https://github.com/kaaaziz/wassitai/issues
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pydantic>=2.11
Requires-Dist: httpx>=0.24
Requires-Dist: json_repair>=0.25
Requires-Dist: xmltodict>=0.13
Provides-Extra: api
Requires-Dist: fastapi>=0.110; extra == "api"
Requires-Dist: uvicorn[standard]>=0.29; extra == "api"
Requires-Dist: python-multipart>=0.0.9; extra == "api"
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: pytest-timeout; extra == "dev"
Requires-Dist: pytest-xdist; extra == "dev"
Requires-Dist: hypothesis>=6; extra == "dev"
Requires-Dist: respx>=0.21; extra == "dev"
Requires-Dist: ruff<0.17,>=0.16; extra == "dev"
Requires-Dist: mypy; extra == "dev"
Requires-Dist: instructor==1.17.0; extra == "dev"
Dynamic: license-file

# Wassitai

[![PyPI](https://img.shields.io/pypi/v/wassitai.svg)](https://pypi.org/project/wassitai/)
[![Python](https://img.shields.io/pypi/pyversions/wassitai.svg)](https://pypi.org/project/wassitai/)
[![CI](https://github.com/kaaaziz/wassitai/actions/workflows/ci.yml/badge.svg)](https://github.com/kaaaziz/wassitai/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

Ask an LLM a question, get back a **validated Python object** — not a string you have to parse.

Wassitai sends your prompt with a schema, picks the best structured-output method the provider
supports, then extracts, repairs, validates and coerces the reply through a deterministic pipeline.
If the result still doesn't fit the schema, it re-asks the model. Ships as the importable package
`wassitai`, with a REST API and a browser playground on top.

```bash
pip install wassitai            # library
pip install "wassitai[api]"     # + REST API and playground
```

**Requirements:** Python 3.9–3.13. Four runtime dependencies — `pydantic>=2.11`, `httpx>=0.24`,
`json_repair>=0.25`, `xmltodict>=0.13`. No vendor SDK is bundled: every provider goes over raw HTTP.

## Contents

[Features](#features) · [Quick start](#quick-start) · [XML answers](#xml-answers) ·
[Capture](#capture) · [REST API](#rest-api) · [How it works](#how-it-works) ·
[Providers](#providers) · [Design choices](#design-choices) · [Guides](#guides) ·
[Development](#development) · [Legacy API](#legacy-api-removed-in-20)

## Features

- **Six provider presets** — Anthropic, Groq, OpenAI, DeepSeek, OpenRouter, Kimi. Any other
  OpenAI-compatible endpoint works with `base_url=` and `model=`.
- **Six ways to write a schema** — Pydantic model; a `{"field": "type"}` or
  `{"model": ..., "attributes": {...}}` dict; JSON Schema; a JSON or an XML schema string.
- **JSON or XML, in and out**, with the format detected from the answer's own shape.
- **Lists** — ask for `list[Invoice]`, get a list back.
- **Repairs before it fails**, lifting JSON out of fences and prose and fixing malformed syntax, so
  the model is re-asked only when repair isn't enough.
- **Strict or flexible validation** — exact types for production, coercion while prototyping.
- **Diagnostics you can act on** — a per-stage report, an intervention score (carried by the
  `confidence` field; it counts repair work, not correctness), and one typed error tree.
- **A playground, not just a library**, plus capture for improving the pipeline from evidence.

## Quick start

```python
from wassitai import Client
from pydantic import BaseModel

class Invoice(BaseModel):
    number: str
    total: float

client = Client.from_provider("groq", api_key="…", model="llama-3.3-70b-versatile")
invoice = client.generate("Extract the invoice from: …", schema=Invoice)   # -> Invoice

# A list, by asking for a list schema:
invoices = client.generate("Extract all invoices: …", schema=list[Invoice])

# Never raises — for batch pipelines:
res = client.try_generate("…", schema=Invoice)     # -> Result[Invoice]
if res.ok:
    print(res.value, res.confidence)

# Text you already have, no provider call:
from wassitai import parse, render
invoice = parse('{"number": "A1", "total": 9.5}', Invoice)
text = render(invoice, "json")
```

`strict=True` enforces exact types; the default coerces close-enough values (`"3"` → `3`).
`max_retries` caps how often the model is re-asked (default 1). The `{"field": "type"}` and
`{"model": ..., "attributes": {...}}` dict forms, and a JSON/XML schema string, are all
type-checked like a Pydantic model.

Nested schemas are compiled to full depth. A nested dict means an object; a
single-element list literal means a list of that shape:

```python
{"organization": {"name": "str", "departments": [{"name": "str"}]}}
```

Two limits remain, both deliberate:

- A dict written as a **real JSON Schema** (`{"type": "object", "properties": {...}}`)
  stays presence-only — declared types and `strict` are not enforced for it. This is
  true at any depth, not just the top level. Presence-only means presence *is* checked:
  the value must be an object and its `required` keys must be there, at depth exactly
  as at the top level. Only the declared **types** inside it are left alone.
- Because the two are told apart by the presence of a `"properties"` or `"type"` key,
  a nested object with a field literally named `type` —
  `{"event": {"type": "str", "payload": "str"}}` — reads as a JSON Schema fragment,
  while `{"manager": {"name": "str"}}` alongside it is fully type-checked. Such a
  fragment declares no recognisable object (`"str"` is not a JSON Schema type), so it
  is left **unconstrained** rather than presence-checked: the engine will not guess
  whether you meant a string field or an object with a field named `type`. Give the
  field another name, or declare the whole schema as a real JSON Schema, if you need
  it enforced.

A dict (or the same dict as a JSON string) carrying a `model` key but no `attributes` —
`{"model": "M"}` — is read as a **bare field map** with one field named `model`, not as
the `{model, attributes}` envelope with a missing `attributes`. Its declared type
(`"M"`) is not a recognised type name, so that field is left unconstrained, same as any
other unrecognised type string — there is no "you forgot `attributes`" error for this
one shape. Give the envelope its `attributes` key if that's what you meant.

A list specification must hold **exactly one** element: it describes the shape of every
item. Two elements raise `SchemaError` rather than the engine guessing which one you meant.
A schema may carry **one** array wrapper (`{"type": "array", "items": …}`), not two: `many`
says the answer is a list of objects and has no second level to record, so a list of lists
raises rather than being silently flattened.

Extraction for a `many` schema recognises only `{...}` JSON objects as list items, so an
item in the answer that is not an object — a number, string, boolean, `null`, or nested
array, anywhere in the list — cannot reach the list. Since 0.5.0 it **fails the run**
rather than disappearing from it: `wassitai.try_parse('[{"n": "A"}, 42, {"n": "B"}]',
schema)` returns `ok=False` with a `validate`-stage error reading `Expected an object`.
The engine will not return the object subset as though it were the whole answer — before
0.5.0 it did, at confidence `1.0` with no diagnostic (issue
[#41](https://github.com/kaaaziz/wassitai/issues/41)).

Two ceilings bound schema **compilation**, which is work a caller can ask for before any
provider is reached. A schema may compile at most **5,000 nodes** — one per field, one per
nested object, counted across the whole schema — and may nest at most **48 levels**, short of
the declared 100 because Pydantic's own `model_json_schema()` exhausts the call stack first.
Measured: 4,999 flat fields compile and 5,000 do not; 48 levels compile and 49 do not. Past
either, `SchemaError` names the limit rather than the schema quietly losing its shape. Both
are far above anything written by hand, and a part of a schema too large to compile can still
be passed as a real JSON Schema, at the cost of presence-only checking.

A **field name Pydantic cannot make into a field** raises `SchemaError` naming it, rather
than being renamed to something that works. Two families: any name beginning with `_`
(Pydantic never builds a field for one, so the field would be declared and not enforced —
and `__base__`, `__config__`, `__doc__`, `__module__`, `__validators__` and `__cls_kwargs__`
are `create_model`'s own parameters), and any `model_`-prefixed name that shadows a
`BaseModel` member. A name that merely shadows some other `BaseModel` attribute — `json`,
`dict`, `copy`, `schema` — is fine and is enforced normally.

An XML schema **string** may not declare entities: `<!ENTITY …>` in the DOCTYPE subset
raises `SchemaError`. The frozen parser expands them, so a schema carrying one cannot be
read for its declared types without expanding it too.

An XML schema **string** cannot express nesting at all: its `<attribute name= type=>`
pairs are flat by construction — there is no syntax for one attribute to contain another.
A nested attempt is **not merely ignored, it is actively rejected**: both the frozen
`core/model_parser` (`root.findall(".//attribute")`) and this adapter's own declared-type
reader search *all* descendant `<attribute>` elements, not only a `<model>`'s direct
children, so a nested attribute's child elements are hoisted into the root model as
additional required top-level fields. A correctly-nested answer then fails validation
with "Field required" for every hoisted child. Pass a dict schema or a JSON schema
string instead if you need nesting enforced. This is a limit of the schema *input*
format only — an XML **response** is validated against a nested schema exactly as a
JSON response is (see "When the model answers in XML" below).

Generation parameters live on `Config` and can be overridden per call:

```python
client.generate("…", schema=Invoice, max_tokens=512, top_p=0.9, stop=["END"])
```

`max_tokens`, `top_p` and `stop` are unset by default and then left off the request entirely, so the
provider's own default applies. `temperature` is the exception: it defaults to `0.0` and is sent on
every request. A provider that declares it does not accept one of these has it dropped instead, and
the run names it in `result.diagnostics`.

`Config(max_tokens_ceiling=…)` is how far a re-ask may raise `max_tokens` once the provider has
reported that its cap — not the model — ended the answer. It defaults to `None`, meaning never
escalate. A ceiling works with `max_tokens` unset (the re-ask jumps straight to it); when both are
set the ceiling must exceed the cap, or `Config` raises `ConfigurationError`.

## XML answers

Nothing asks for XML — structured-output APIs speak JSON — but models ignore instructions, and
stored answers get replayed through `parse()`. An XML body reads into the same validated object a
JSON one produces:

```python
parse("<person><name>Ada</name><age>36</age></person>", Person)   # Person(name='Ada', age=36)
parse('<person name="Ada" age="36"/>', Person)                    # the same
parse("<ns:person><ns:name>Ada</ns:name></ns:person>", Person)    # and the same
```

Five shapes XML cannot express follow documented rules, each recorded as a diagnostic:

| in the answer | what happens |
|---|---|
| `<r id="7">` — an attribute | reaches the `id` field, **unless** a sibling `<id>` is also present: then neither is renamed, both survive, and the collision is a warning |
| `<ns:name>` — a namespace prefix | same rule, same reason: stripped when the local name is free, kept when it would collide |
| `<tag>x</tag>` once, for a `list[str]` field | wrapped into `["x"]` — the format renders a list of one exactly like a scalar |
| `<name/>` — an empty element | stays `None`, never `""` — a value the model never sent |
| `<r>text<child/>more</r>` — mixed content | a `RepairError`; any rule invented for it would produce plausible, silently wrong data |

**`strict=True` cannot enforce scalar types on an XML body**, because the format does not carry
them: every leaf is a string, so `<age>36</age>` and `<age>"36"</age>` are the same document.
Declared `int`/`float`/`bool` fields are therefore converted before validation in both modes, and
each conversion is recorded. A value that is genuinely not a number is left alone and still fails.

## Capture

Write failing and repaired runs to disk, so the pipeline can be improved from evidence rather than
memory.

```python
from wassitai import Capture, Client, JsonlSink

with Capture(sink=JsonlSink("./captures"), experiment="tool-calling-vs-prompt-repair") as capture:
    client = Client.from_provider("groq", api_key="…", capture=capture)
    client.try_generate("List the invoice lines", schema=Invoice)

print(capture.stats())
```

Two files appear per process: a `.jsonl` of records and a `.manifest.json` of counters. By default
runs that fail **and** runs that needed repair are captured; pass `when="failures"` or `when="all"`
to change that.

A record holds the prompt, the schema, every attempt's raw answer, the parsed result, the error
detail, provider and model names, strategy, config, token usage and timestamps. **Treat records as
sensitive.** Your configured API key is removed from every record automatically; everything else is
stored as sent unless you supply a `redact` callable.

```python
Capture(sink=JsonlSink("./captures"), experiment="e",
        redact=lambda text: EMAIL_RE.sub("[email]", text),
        max_field_bytes=16_384)
```

A sink failure — full disk, read-only volume — warns once, disables capture, and lets your run
continue. → [Python guide](docs/guides/python.md#capture) for what is exempt from redaction, how
truncation is reported, and `strict_sink`.

## REST API

```bash
uvicorn wassitai.api.app:app --reload
```

Playground at `http://localhost:8000/`, beginner's guide at `/static/guide.html`, OpenAPI reference
at `/docs`.

| Method | Path | Does |
|---|---|---|
| `POST` | `/v1/generate` | prompt + schema → validated object |
| `POST` | `/v1/parse` | text + schema → validated object (no provider call) |
| `GET` | `/v1/providers` | providers and their capabilities |
| `GET` | `/health` | liveness, and whether capture would really store a run |

```bash
curl -X POST http://localhost:8000/v1/generate \
  -H "X-Provider-Key: $GROQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider":"groq","prompt":"Extract the invoice: …",
       "schema":{"number":"str","total":"float"}}'
```

Provider keys travel per-request in the `X-Provider-Key` header (or come from `<PROVIDER>_API_KEY`
on the server) and are never stored, logged, or pre-filled into the browser. Errors share one shape
and map to status codes: `400` unknown provider · `401` auth · `413` body too large · `422` the
request or the answer did not validate, the schema could not be read, the run could not be set up,
or the answer could not be rendered · `429` rate limit · `500` server-side · `502` provider ·
`504` timeout.

Both endpoints accept `include_record`, `capture_consent` and `experiment`. A run is **stored** only
when `capture_consent` is true *and* `WASSITAI_CAPTURE_DIR` is set on the server — either alone
stores nothing, and `GET /health` reports `storage_active` for the real answer. Every attempted run
comes back with a `run_id`, the handle for asking that a stored run be deleted.

Request size is bounded by `WASSITAI_MAX_BODY_BYTES` (1 MiB) and, on `/v1/parse`, by
`WASSITAI_MAX_TEXT_CHARS` (262144 characters) — backstops, not a substitute for a limit at your own
proxy. → [REST guide](docs/guides/rest.md)

## How it works

`generate()` → schema adapter → strategy → provider → **pipeline** → validated object.

- **Strategy** — resolved per provider in order `native` (JSON-schema mode) → `tool_calling` →
  `prompt_repair`, which is the universal floor and needs nothing from the provider.
- **Pipeline** — six ordered stages: `normalize → extract → syntax_repair → deserialize →
  validate → coerce`. Each records a diagnostic, and the intervention score falls by how much work a
  stage had to do — 0.05 to lift the payload out of surrounding prose, 0.20 to repair syntax, 0.25
  when a record had to be discarded (a second object, an entry cut off mid-stream, or an enclosing
  container that never closed), 0.30 to re-ask.
- **Extending** — `Provider`, `Strategy`, `SchemaAdapter` and `Stage` are the four interfaces; most
  providers need only a preset in `wassitai/providers/__init__.py`.

Errors form one tree under `LLMParserError`: `ConfigurationError` (your bug), `ProviderError` (the
network's or the provider's), and `ParseError` (the model's).

## Providers

Presets speaking the OpenAI wire format: **groq**, **openai**, **deepseek**, **openrouter**,
**kimi**. **anthropic** is the exception — it speaks `/v1/messages` through its own `Provider` class
behind the same interface, and defaults to `claude-opus-5`.

`openrouter` and `kimi` carry no default model, so pass `model=`. Each preset declares what its
provider actually accepts, so a sampling parameter the vendor rejects is left off the wire and named
in `diagnostics` rather than sent and refused.

Any other OpenAI-compatible endpoint — Together, xAI, Ollama, vLLM, a local server — works by
passing `base_url=` and `model=`. Native adapters for Gemini and Bedrock are planned behind the same
`Provider` interface.

## Design choices

Each is a trade-off rather than a free feature, so the cost is stated alongside.

| Choice | What it buys | What it costs |
|---|---|---|
| **Repair, then re-ask** | A malformed reply is fixed locally before the model is asked again, so most bad answers never cost a second call | A repaired answer is not a clean one; read `confidence` and `diagnostics` rather than treating success as uniform |
| **Two validation modes** | Coerce while prototyping, enforce exact types in production, without editing the schema | `strict` means different things per format — XML cannot carry types, so scalars are converted in both modes |
| **JSON *and* XML in** | A stored or off-spec answer parses to the same object; format is detected from the answer's own shape | XML has no type information, and five of its shapes need documented rules |
| **Capability-driven strategies** | The provider decides the method — native, tool calling, or the universal prompt floor | A provider that declares its capabilities dishonestly degrades silently, so presets are tuned per provider |
| **Raw HTTP, no vendor SDKs** | Four runtime dependencies; a new OpenAI-compatible provider is usually one preset line | Non-OpenAI wire formats need a `Provider` class, as Anthropic has |
| **Two doors** | `generate`/`parse` raise; `try_generate`/`try_parse` return a `Result` | Two call styles to learn — pick the one that fits the caller |

## Guides

- [`docs/guides/python.md`](docs/guides/python.md) — every exported object: `Config` field by field,
  `Result`, the full 22-type error tree, capture in depth, and which strategy gets chosen when
- [`docs/guides/rest.md`](docs/guides/rest.md) — the HTTP surface: keys, the shared error shape,
  every status code, the two size limits, and capture over the wire
- [`docs/guides/versioning.md`](docs/guides/versioning.md) — which names carry a promise, what 1.0
  means, and how long a deprecation lasts

With the API running, `/docs` carries the generated field-by-field reference and
`/static/guide.html` is the beginner's walkthrough.

## Development

```bash
pip install -e ".[api,dev]"              # editable install with the API and test deps
pytest                                   # 2,792 tests, no network calls unless you opt in
pytest -m unit                           # or: integration, e2e, provider
pytest -n auto                           # parallel
pytest --cov=wassitai --cov-report=html
```

A live provider run needs both `WASSITAI_LIVE=1` and `GROQ_API_KEY`; see `tests/bench/`. CI runs the
same suite on Python 3.9–3.13, plus `ruff`, a blocking `mypy` leg, a Docker build-and-smoke-test,
and a `floor` leg that installs every declared minimum version to keep the dependency floor honest.

Release notes for every version are in [`CHANGELOG.md`](CHANGELOG.md).

## Legacy API (removed in 2.0)

`LLMParser.ask` still works and still supports Gemini and HuggingFace, but is deprecated.

| Old | New |
|---|---|
| `LLMParser("groq", api_key=k).ask(q, schema=S, fmt="json")` | `Client.from_provider("groq", api_key=k).generate(q, schema=S)` |
| `...ask(q, schema=S, many=True)` | `...generate(q, schema=list[S])` |
| `strict_validation=True` | `generate(..., strict=True)` |
| need the JSON string back | `render(obj, "json")` |

The current engine ships no preset for Gemini or HuggingFace, so on the new path they go through
`Client.from_provider(name, api_key=…, base_url=…, model=…)` like any other OpenAI-compatible
endpoint — or open an issue if a preset would be more useful.

## License

MIT.

## Contact

Issues and discussions on [GitHub](https://github.com/kaaaziz/wassitai) ·
kaa.kella.abdelaziz@gmail.com
