Metadata-Version: 2.4
Name: disclarion
Version: 0.5.0
Summary: AI disclosure logging SDK for Disclarion
Author-email: Disclarion <hello@disclarion.com>
License: MIT
License-File: LICENSE
Requires-Python: >=3.9
Requires-Dist: anthropic>=0.34
Requires-Dist: google-genai>=1.0
Requires-Dist: openai>=1.0
Requires-Dist: pillow>=10.1
Requires-Dist: requests>=2.28
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# disclarion

AI disclosure logging SDK. Wraps your LLM provider responses and reports
standardized disclosure/labeling metadata to Disclarion.

## Install

```bash
pip install disclarion
```

## Usage

```python
from disclarion import Disclarion
import openai

client = openai.OpenAI()
dc = Disclarion(api_key="dcl_live_...")

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "hello"}],
)
dc.track(response, session_id="session-123")
```

`track()` never modifies `response` by default — it attaches a
`disclarion_meta` dict to it instead, and your own UI decides what to show:

```python
tracked = dc.track(response, session_id="session-123")
tracked.disclarion_meta
# {"ai_generated": True, "is_first_message": True, "disclosed": False, "logged_at": "..."}
```

`is_first_message` is what a host-app disclosure widget should key off of —
show a one-time blocking notice on `True`, keep a small persistent badge up
for the rest of the session on `False`. A ready React and vanilla-JS
implementation of exactly that pattern ships inside every install — find it
with:

```bash
python -c "import disclarion; print(disclarion.widget_dir())"
```

This is the `disclosure_mode="modal_once"` default, set on the client, not
per call:

- **`modal_once`** (default) — never touches response content; only
  `disclarion_meta` changes, as above.
- **`inline_every_message`** — the SDK's original behavior, kept for
  backward compatibility. Despite the name, it still only rewrites the
  response's own content on a session's *first* message (never later
  ones) to append/prepend `disclosure_text`; `disclarion_meta["disclosed"]`
  reflects whether that happened.
- **`off`** — pure logging, no disclosure signal at all:
  `disclarion_meta` omits `is_first_message`/`disclosed` entirely. Use
  this if your product already has its own unrelated AI-disclosure UI.

```python
dc = Disclarion(api_key="dcl_live_...", disclosure_mode="inline_every_message")
```

Regardless of `disclosure_mode`, every `track()` call is still logged to
Disclarion's audit trail (`interaction_logs`) exactly the same way — the UI
mode only changes what happens to `response` and `disclarion_meta`, never
what gets recorded for compliance.

`track()` makes one fast synchronous attempt (that's what makes
`is_first_message` available immediately) and never retries inline — a
slow or erroring Disclarion backend can never turn into multi-second
latency on your own request path. Anything worth retrying (a network
error, a `5xx`, a transient `429`) is handed to a background thread with
a bounded queue (up to 1000 pending logs, oldest dropped first if your
process is generating logs faster than Disclarion can accept them) that
retries with capped, jittered exponential backoff, honoring a `429`'s
`Retry-After` header when present. The queue is flushed on normal
process exit, `SIGINT`, and `SIGTERM` (bounded to a couple of seconds, so
shutdown is never blocked indefinitely by a backend that's still down).

A `429` that means "you've exceeded your monthly plan quota" is handled
differently from a transient rate limit: retrying it is pointless until
next period, so the SDK opens a circuit breaker instead — every `track()`
call skips the network entirely (sub-millisecond) until an hourly (then
exponentially longer, capped at 24h) cooldown elapses and one probe
request checks whether it's resolved. One `logging.warning` marks each
state change (opened/resolved), not one per call, so a month-long outage
doesn't spam your logs. Call `dc.get_stats()` any time for the queue size,
circuit state, and delivery counters.

An invalid or revoked API key is different from all of the above: the
backend is reachable and is explicitly rejecting it, which is a setup
mistake you need to see, so that raises `disclarion.AuthenticationError`
immediately — no retry, no circuit-breaking, no silent fallback. An
inactive subscription (trial ended, payment failed) is treated the same
way: it raises `disclarion.SubscriptionInactiveError` immediately, for
the same reason (**new in 0.3.0** — before this, a `402` fell through the
generic failure path above and was silently swallowed at `DEBUG` level,
which meant a lapsed subscription could leave a real, permanent gap in
your audit trail with no visible symptom; this is an intentional breaking
change, see `CHANGELOG.md`). Unsupported response types raise a
`ValueError` immediately for the same reason: all three are programming/
account-state errors to fix, not a runtime condition to swallow.

OpenAI (`ChatCompletion`), Anthropic (`Message`), and Gemini
(`GenerateContentResponse`) responses are all supported — see
`disclarion/adapters/`.

## Jurisdiction and interaction type

`track()` also takes `jurisdiction` (default `"EU"`) and `interaction_type`
(default `"chat"`):

```python
dc.track(response, session_id="session-123", jurisdiction="US-CA", interaction_type="chat")
```

These feed Disclarion's backend Obligation Engine, which decides which
obligations (e.g. `disclosure_modal`, `audit_log`) actually apply to this
pair — the SDK has no built-in notion of "EU Article 50" or any other
specific regulation, it only acts on whatever `required_actions` the
backend returns. The defaults (`"EU"`, `"chat"`) match the original rule,
so every caller from before this parameter existed sees identical
behavior without passing these. Any jurisdiction/interaction type
combination without a configured rule is still logged in full (the audit
trail is never gated by obligations), it just comes back with no
obligations — `disclarion_meta` omits `is_first_message`/`disclosed` for
that call, the same shape as `disclosure_mode="off"`.

Check what a pair currently requires without logging anything, with the
same API key:

```bash
curl "https://api.disclarion.com/v1/obligations?jurisdiction=EU&interaction_type=chat" \
  -H "Authorization: Bearer dcl_live_..."
# {"data": {"jurisdiction": "EU", "interaction_type": "chat", "required_actions": ["disclosure_modal", "audit_log"]}, "error": null}
```

There's no SDK wrapper for this endpoint yet — it's a plain side-effect-free
`GET`, same auth as everything else.

## Labeling AI-generated content

`disclosure_modal` is about chatbots: a live conversation where a user
needs a heads-up they're talking to an AI. Not everything `track()` logs
is a conversation — a product description, a social media post, or an
article drafted with an LLM is AI-generated content published somewhere,
with no "session" a disclosure modal would ever attach to. For that,
pass `interaction_type="generated_content"`:

```python
dc.track(response, session_id="post-42", interaction_type="generated_content")
```

Today `("EU", "generated_content")` is the one configured rule for this,
and it returns a different obligation, `content_label`, not
`disclosure_modal`. Unlike disclosure, this isn't a live UI component and
isn't gated by `disclosure_mode` at all — when it applies,
`disclarion_meta` just gets a plain signal:

```python
tracked = dc.track(response, session_id="post-42", interaction_type="generated_content")
tracked.disclarion_meta
# {"ai_generated": True, "logged_at": "...", "content_label_required": True}
```

There's no widget for this (yet — see the roadmap): `content_label_required`
is a flag, not a component. It's up to your own content template to decide
what that looks like — an "AI-generated" caption under a product
description, an `aria-label`, a byline note on a blog post. The
interaction is logged exactly the same way regardless.

### Non-text content (images, video, audio): `track_content()`

`dc.track(response, ...)` only works with a response object one of its
adapters can introspect — OpenAI `ChatCompletion`, Anthropic `Message`,
Gemini `GenerateContentResponse`. That's fine for text drafted via a
chat completion (pass it to `track()` as above), but an image/video/audio
generation call returns something else entirely, with none of the fields
those adapters read (checked against OpenAI's own `ImagesResponse`:
no `.id`, no `.model`). For those, use `track_content()` instead — it
takes `provider`/`model_name` directly rather than trying to detect them
from a response, since you already know both from your own generation
call:

```python
image = openai_client.images.generate(model="dall-e-3", prompt="...")

meta = dc.track_content(
    provider="openai",
    model_name="dall-e-3",
    session_id="product-photo-42",
    content_type="image",  # free text — "image", "video", "audio", "text", ...
)
meta
# {"ai_generated": True, "logged_at": "...", "content_label_required": True}
```

Always logs as `interaction_type="generated_content"` (it isn't a
parameter here — there's no chat-vs-content ambiguity for this method).
Returns the `disclarion_meta` dict directly, since there's no response
object to attach it to. `content_type` is optional and purely
descriptive today (it doesn't change which obligations apply, only
what's recorded for later reporting) — pass whatever describes the
content, there's no fixed list.

### The standard Disclarion label

`track_content()` also produces a fixed, non-customizable bottom-left
visual label for the content you just logged — a small "AI-generated"/
"AI-assisted" badge plus a "Labeled by Disclarion" credit. It satisfies
the `content_label` obligation above with a ready-made mark instead of
you designing your own. There's no API to restyle it: a label a customer
could reskin to look like something else would defeat the point of a
*standard*, recognizable one.

It comes back in two forms — use whichever matches what you're labeling:

```python
meta = dc.track_content(
    provider="openai",
    model_name="dall-e-3",
    session_id="product-photo-42",
    content_type="image",
    image=image_bytes,  # optional: raw PNG/JPEG/WebP bytes to watermark
)

meta["badge_html"]         # HTML/CSS snippet, for text/article content
meta["watermarked_image"]  # bytes, only present when `image=` was passed
meta["content_labeled"]    # True iff a label was actually applied
```

- **`badge_html`** — an HTML/CSS snippet for text or article content.
  Place it as the last child inside the container wrapping your content.
  **That container needs `position: relative`** (or any non-`static`
  position) — the one thing this snippet can't set for you, since
  `position: absolute` positions against the nearest positioned ancestor,
  and without one that's an arbitrary scroll ancestor, not your content
  block. Its "Labeled by Disclarion" credit is a real, clickable link to
  `https://www.disclarion.com/verify/{id}` — a public page confirming
  this specific record is real (timestamp, provider, content type; never
  the content itself), not just a generic link to disclarion.com.
- **`watermarked_image`** — only produced when you pass `image=<raw
  bytes>` (PNG, JPEG, or WebP). The same badge baked into the image's own
  pixels via Pillow, so it travels with the file through a re-upload or
  re-host. Because it's pixels, not markup, its credit line is **plain
  text, not a link** — a raster image has no clickable regions, so this
  is the one thing the pixel form genuinely can't do that `badge_html`
  can. A very small image (shorter side under ~320px) can't fit a legible
  badge: watermarking is skipped rather than shrunk into unreadability,
  `watermarked_image` is left out of the dict, and you instead get
  `watermark_skipped: True` with `watermark_skip_reason: "image_too_small"`.

`content_labeled` — also written to the audit trail and shown in the
dashboard's Logs table — reflects whether a label was actually applied to
*this* piece of content: always `True` for the HTML badge (no size
constraint), and for images, `True` only when the watermark wasn't
skipped.

Two more parameters, both optional:

- `ai_involvement`: `"generated"` (default, shows "AI-generated") or
  `"assisted"` (shows "AI-assisted", for content a human substantially
  edited/directed). No other values.
- `include_disclarion_link`: `True` by default; pass `False` to drop the
  "Labeled by Disclarion" credit line from both forms and keep only the
  required AI-disclosure text.

**Known limitation, stated plainly**: this is a visible label, not
tamper-proofing. A crop that excludes the bottom-left corner removes it,
the same way it would remove a printed photo credit — it is never
described as unbreakable or unremovable.

## Using this with an async framework (FastAPI, aiohttp, Starlette, ...)

`track()` is synchronous under the hood (it uses `requests`) and blocks for
up to `FAST_PATH_TIMEOUT_SECONDS` (currently 1.5s) even on a healthy
backend. Called directly inside an `async def` handler, that blocks the
whole worker's event loop for that long — stalling every other concurrent
request on the same worker, not just the one that called `track()`. Get it
off the event loop:

```python
# FastAPI / Starlette
from starlette.concurrency import run_in_threadpool

tracked = await run_in_threadpool(dc.track, response, session_id=session_id)
```

```python
# Any asyncio app, no Starlette dependency
import asyncio

tracked = await asyncio.to_thread(dc.track, response, session_id=session_id)
```

This applies even if the backend you're calling is your own service — a
synchronous `track()` call made from inside an async handler can, in the
worst case, block that same process against itself.

## Testing without a live backend

To exercise `_send_log()` end to end without hitting the real
`api.disclarion.com`:

```bash
python scripts/mock_server.py        # terminal 1 - fake /v1/logs endpoint
python scripts/manual_test.py        # terminal 2 - sends a fake ChatCompletion
```

The mock server prints every received payload and the `Authorization`
header so you can confirm the normalized log shape is correct. See
`scripts/README.md` for details, or `tests/` for the automated unit tests
(`pytest`) which mock the HTTP layer instead of needing a running server.
