Metadata-Version: 2.5
Name: multinodal
Version: 0.1.0
Summary: Python client for the Multinodal API: collections, documents, knowledge graphs, exports and events.
Project-URL: Homepage, https://multinodal.dev
Project-URL: Source, https://github.com/multinodal-ai/python-sdk
Author: Multinodal AI
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: api,evidence,knowledge graph,multinodal,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
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: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.27.0
Requires-Dist: pydantic<3,>=2.7.0
Description-Content-Type: text/markdown

# multinodal

The Python client for the Multinodal API.
Multinodal builds a knowledge graph from your Documents.
Each Relationship in the graph carries Evidence: the verbatim quote that supports it.
This client uploads Documents, waits for processing, reads the graph, and writes exports.

This guide takes you from installation to an export file on disk.

## Contents

1. [Install](#1-install)
2. [Create an API key](#2-create-an-api-key)
3. [Configure the client](#3-configure-the-client)
4. [Upload Documents](#4-upload-documents)
5. [Wait for processing](#5-wait-for-processing)
6. [Read Relationships and their Evidence](#6-read-relationships-and-their-evidence)
7. [Export the graph](#7-export-the-graph)
8. [Use the async client](#8-use-the-async-client)
9. [Handle errors](#9-handle-errors)
10. [Retries and timeouts](#10-retries-and-timeouts)
11. [Known gaps](#11-known-gaps)
12. [Recipe notebook](#12-recipe-notebook)
13. [License](#13-license)

## 1. Install

The client needs Python 3.10 or later.
Its only runtime dependencies are `httpx` and `pydantic`.

```bash
pip install multinodal
```

> **Release status.** Version 0.1.0 is not on PyPI yet.
> The owner publishes it.
> Until then, install from a clone of this repository:
>
> ```bash
> git clone https://github.com/multinodal-ai/python-sdk.git
> pip install ./python-sdk
> ```

## 2. Create an API key

1. Sign in at <https://multinodal.dev>.
2. Open **Settings → API Keys**.
3. Select **Create new key**, and enter a name.
4. Select the access level.
   - **Read only** reads Collections, Documents, the graph, exports, and usage.
   - **Read and write** also creates, changes, and deletes Collections and Documents. It spends Page Credits.
5. Copy the key. It starts with `mn_`. The page shows it once.

This guide uploads Documents, so it needs a **Read and write** key.
A write with a **Read only** key fails with the `forbidden` error.

Processing spends Page Credits.
A Document costs one Page Credit per page, at the rate of its Pricing Band.
PDF and DOCX use Band A. TXT and Markdown use Band B.
A failed Document costs nothing.
Top up your Credit Balance in the webapp before you upload.

## 3. Configure the client

Set the key in the environment:

```bash
export MULTINODAL_API_KEY="mn_..."
```

Create the client:

```python
from multinodal import Multinodal

mn = Multinodal()
```

The client reads two environment variables.

| Variable | Required | Default |
| --- | --- | --- |
| `MULTINODAL_API_KEY` | Yes | None |
| `MULTINODAL_BASE_URL` | No | `https://api.multinodal.dev` |

You can pass both values as arguments instead:

```python
mn = Multinodal(api_key="mn_...", base_url="https://api.multinodal.dev")
```

Do not hard-code the host.
The API host can move to a different domain.
The client follows `MULTINODAL_BASE_URL`, so a move needs no code change.

Check your Credit Balance before you spend it:

```python
usage = mn.usage.me()
print(usage.balance_micros)  # an int, in USD micros
print(usage.balance)  # the same amount as a Decimal, in USD
```

Money is always an `int` of USD micros, or a `Decimal`.
It is never a `float`.

## 4. Upload Documents

Create a Collection.
A Collection holds Documents and one knowledge graph.
Entities are reconciled across all Documents in the same Collection.

```python
col = mn.collections.create(name="Filings")
print(col.id)  # save this id to reopen the Collection later
```

A Collection name has 1 to 30 characters.

Upload Documents from local paths:

```python
batch = col.documents.upload(["a.pdf", "b.pdf"])
```

The accepted formats are PDF, TXT, Markdown, and DOCX.
One call accepts up to 10 Documents.
`upload` returns when the server accepts the Documents.
Processing then runs in the background.

To reopen a Collection later, pass its saved id:

```python
col = mn.collection(saved_id)  # a handle; it makes no network call
```

## 5. Wait for processing

```python
documents = batch.wait_until_done(timeout=600)
for doc in documents:
    print(doc.filename, doc.status)
```

`wait_until_done` returns when every Document in the batch reaches a terminal status.

| Status | Meaning | Billed |
| --- | --- | --- |
| `DONE` | Processing succeeded. | Yes |
| `DONE_WITH_WARNINGS` | Processing succeeded with degraded extraction. | Yes |
| `ERROR` | Processing failed. `doc.error_message` holds the reason. | No |

A Document that only partly succeeds is billed in full.

The client does not poll.
It follows the event stream (`GET /api/v1/events`) over server-sent events.
It opens the stream first, then reads each Document once, so it misses no event.
The server keeps events for 7 days, and the stream replays missed events in order.
A dropped connection resumes from the last event id.

The call raises `WaitTimeout`, a subclass of `TimeoutError`, when `timeout` seconds pass first.
Processing continues on the server.
Call `wait_until_done` again to keep waiting.

## 6. Read Relationships and their Evidence

A Relationship is a directed, typed assertion between two Entities.
Each one carries Evidence: the verbatim quote from the Document text that supports it.

```python
for rel in col.graph.relationships(type="works_at"):
    print(rel.subject.name, rel.type, rel.object.name)
    print("  Evidence:", rel.evidence.text)
    print("  offset:", rel.evidence.offset, "verified:", rel.evidence.verified)
```

- `type` filters on one relation type. Omit it to read every Relationship.
- Relation types are plain strings. The Documents produce them, so no fixed list exists.
- The iterator fetches pages as you read. It does not load the whole graph at once.
- `rel.evidence.verified` is `True` when the pipeline found the quote in the Document text.
  A quote that matches only after pronouns are resolved also counts as verified.
- `rel.evidence.offset` is the character position of the quote in the Document's extracted text.
  It is `None` when the quote matched only after pronoun resolution, or did not match.

Inferred Relationships are a separate type.
They come from reasoning over other Relationships, not from the text.
They carry no Evidence.

```python
for inf in col.graph.inferred_relationships():
    print(inf.subject.name, inf.type, inf.object.name, inf.rule, inf.hops)
```

Check for an empty graph before you read it:

```python
if col.graph.is_empty():
    print("Processing finished, but no Relationships were extracted.")
```

A Collection can finish processing with no Relationships.
See [Known gaps](#11-known-gaps).

Every model is plain Pydantic data underneath:

```python
col.model_dump()
```

## 7. Export the graph

```python
col.export("cypher", to="graph.cypher")
```

The export streams to the file in chunks.
If the connection breaks mid-stream, the client deletes the partial file and raises `IncompleteExport`.
You never get a truncated file that looks complete.

| Format | Argument | Write it to |
| --- | --- | --- |
| Cypher | `"cypher"` | `graph.cypher` |
| JSON-LD | `"jsonld"` | `graph.jsonld` |
| Turtle | `"turtle"` | `graph.ttl` |
| GraphML | `"graphml"` | `graph.graphml` |
| NetworkX Parquet | `"parquet"` | `graph.zip` |

The parquet export is a ZIP file that holds `nodes.parquet` and `edges.parquet`.
The server builds it in memory before it sends the first byte.
A large Collection waits longer before the download starts.

You now have an export file on disk.

## 8. Use the async client

`AsyncMultinodal` has the same surface as `Multinodal`.
Await each call, and use `async for` on each iterator.

```python
import asyncio
from multinodal import AsyncMultinodal


async def main() -> None:
    amn = AsyncMultinodal()
    col = await amn.collections.create(name="Filings")
    batch = await col.documents.upload(["a.pdf", "b.pdf"])
    await batch.wait_until_done(timeout=600)
    async for rel in col.graph.relationships(type="works_at"):
        print(rel.subject.name, rel.evidence.text)
    await col.export("cypher", to="graph.cypher")


asyncio.run(main())
```

A handle from `amn.collection(saved_id)` holds only the id.
Call `await col.load()` before you read its fields, such as `col.name`.
A handle that `create`, `get` or `update` returns is already loaded.

Use `AsyncMultinodal` inside async code.
The sync `wait_until_done` can block for minutes.
Inside an event loop, that blocks every other task.

## 9. Handle errors

Every API error raises a subclass of `MultinodalError`.
The error carries the RFC 7807 problem fields: `type`, `title`, `status`, `detail`, and `instance`.
A `validation-failed` error also carries `errors`, one entry per invalid field.

Match on the slug, not on the full `type` URI.
The slug is the last segment of `type`, such as `out-of-credits`.
The slug never changes. The host in the URI can change.

```python
from multinodal import MultinodalError, OutOfCredits

try:
    batch = col.documents.upload(["a.pdf"])
except OutOfCredits:
    print("Top up your Credit Balance at https://multinodal.dev.")
except MultinodalError as err:
    print(err.slug, err.status, err.detail)
```

The client maps each slug to its own exception class.
An unknown slug raises `MultinodalError` itself.

| Slug | Status | What to do |
| --- | --- | --- |
| `unauthorized` | 401 | Set `MULTINODAL_API_KEY`. |
| `invalid-key` | 401 | Check the key. Create a new one if you lost it. |
| `key-revoked` | 401 | Create a new key. |
| `forbidden` | 403 | Use a **Read and write** key for writes. |
| `not-found` | 404 | Check the id. A key reads only its own owner's resources. |
| `validation-failed` | 400 | Read `err.errors` for the invalid fields. |
| `invalid-cursor` | 400 | Restart the iteration. |
| `out-of-credits` | 402 | Top up your Credit Balance. |
| `budget-exceeded` | 402 | Raise your Budget, or wait for the next period. |
| `credits-exhausted` | 402 | Top up your Credit Balance. |
| `document-too-large` | 413 | Split the Document. |
| `unsupported-file-type` | 415 | Convert it to PDF, TXT, Markdown, or DOCX. |
| `document-not-retryable` | 409 | Only a failed Document can be retried. |
| `rate-limit-exceeded` | 429 | The client retries it for you. See below. |
| `events-expired` | 410 | The client handles it inside `wait_until_done`. |
| `pricing-uncalibrated` | 503 | Try again later. |
| `internal-error` | 500 | Try again later. Report it if it persists. |

Each `type` URI opens a page that describes the error.
The API host lists every slug at `/errors/`.

## 10. Retries and timeouts

The API has no idempotency keys.
So the client retries only a request that is safe to send twice.

| Method | Retried on |
| --- | --- |
| `GET`, `DELETE` | 429, 502, 503, 504, and connection errors |
| `POST`, `PATCH` | 429 and connection errors only |

A connection error means the request never reached the server.
A 429 means the server refused it before doing any work.
Both are safe to resend.

A timed-out upload request is **not** resent.
The server may have received it and started processing.
A second send could process and bill the Document twice.
After an upload request times out, list the Collection's Documents before you upload again:

```python
for doc in col.documents.list():
    print(doc.filename, doc.status)
```

Backoff is exponential with full jitter.
The client honours the `Retry-After` header on a 429.

The client sets separate connect, read, write, and pool timeouts.
You can override them per call.

## 11. Known gaps

These are current limits of version 0.1.0.
None of them is a promise of a fix.

- **Evidence does not name its source Document.**
  Evidence carries `text`, `offset`, and `verified`.
  It carries no Document id.
  In a Collection with several Documents, you cannot trace a quote to the Document it came from.
  `offset` locates the quote only inside that unnamed Document.
- **Inverse relation types are not collapsed.**
  `works_at` and `employs` stay two separate types.
  A filter on `works_at` does not return assertions recorded as `employs`.
  To read a relationship completely, query each direction by name.
  The measured rate of inverse pairs is low, and collapsing them would break existing queries.
- **A Document can finish with an empty graph.**
  A `DONE` Document can yield no Relationships.
  Check `col.graph.is_empty()`.
  An empty graph is not a client error.
- **Parquet export is buffered on the server.**
  The other four formats stream.
  Parquet waits until the whole file is built.
- **The relation-type vocabulary is unbounded.**
  Types come from the Documents.
  Discover them from the graph you receive, not from a schema.

## 12. Recipe notebook

[`notebooks/retrieval_over_the_graph.ipynb`](notebooks/retrieval_over_the_graph.ipynb) shows retrieval over the graph.
It uses a frozen corpus of two public-domain PDFs in [`notebooks/corpus/`](notebooks/corpus/SOURCES.md).
Running it spends 8 Page Credits at the Band A rate.
The notebook is a recipe for you to run and measure yourself.
It makes no claim about accuracy or quality.

## 13. License

Apache License 2.0. See [LICENSE](LICENSE).
