Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ All notable changes to this project are recorded here. The format follows Keep a
### Documentation
- README: turning on analytics in every flow a contact can pass through is now a deployment step (section 3, step 2), not just a prerequisite. The stock transfer-to-queue flow has no analytics block, so without it the leg after an agent transfer is never analysed. Added a check that a test transfer produces two analysis files, and a troubleshooting entry for "I can see the bot's turns but not the human agent's".

### Fixed
- Spec v0.3: removed HTTP Basic auth and the `429` response from the documented
contract. Neither is implemented by any ciopulse endpoint; the forwarder has
only ever used `X-API-Key`. Documentation correction — no payload that was
valid under v0.3 becomes invalid.
- Spec v0.3: voice now correctly documented as requiring contract_version
"0.2" or later, not exactly "0.2".
- Mock receiver: no longer accepts HTTP Basic, so it can no longer certify a
payload the real endpoint would reject.
- Mock receiver: `escalation_to_human` events now require a valid ISO `ts`,
which the bot/human turn-split fallback depends on.
- README: the contract reference no longer lists a `429` response, matching the spec.

## [0.2.0] - 2026-09-18

Sends send-a-copy contract **v0.3**. Every valid v0.2 payload is also valid v0.3, so a receiver that accepts 0.3 needs no other change to keep working.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ The payload follows the **send-a-copy contract v0.3**: v0.2 plus optional `turns
- Endpoint: `POST https://app.cio-pulse.com/api/v5/ai-agent/transcripts`
- Auth: `X-API-Key` header
- Limits: 1 MB, 500 turns, `metadata` ≤ 2 KB
- Response: `202` with a receipt; `400` with field-level problems; `401`; `413`; `429` with `Retry-After`
- Response: `202` with a receipt; `400` with field-level problems; `401`; `413`. Rate limiting is not currently applied; the forwarder still retries any `429` with backoff
- Full text: [docs/send-a-copy-spec-v0.3.md](docs/send-a-copy-spec-v0.3.md). The previous version stays at [docs/send-a-copy-spec-v0.2.md](docs/send-a-copy-spec-v0.2.md).

## 9. Design notes
Expand Down
7 changes: 4 additions & 3 deletions docs/send-a-copy-spec-v0.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

**Status:** Decided · 18 Sep 2026 · Contact: ciopulse
**Changes from v0.2:** two optional fields, both decided 18 Sep 2026. `turns[].actor` says whether an `agent` turn came from a bot or a person. `conversation_id` joins the sessions of one conversation that a platform split across a transfer, and is the value the survey `tid` carries. Every valid v0.2 payload is a valid v0.3 payload; receivers accept 0.2 and 0.3 side by side for the life of v0.x.
**Corrections, 21 Sep 2026:** removed HTTP Basic and the `429` response, neither of which any ciopulse endpoint implements; clarified that voice requires `"0.2"` or later. Documentation only — no payload valid under v0.3 becomes invalid.
**Changes from v0.1 (v0.2):** `channel` accepts `"voice"`; optional `platform_signals` block.

---
Expand All @@ -15,7 +16,7 @@ POST https://app.cio-pulse.com/api/v5/ai-agent/transcripts
Content-Type: application/json
```

**Auth** (either): HTTP Basic — username = portal code, password = API key · or `X-API-Key` header. Both supplied by ciopulse at onboarding. HTTPS only.
**Auth:** `X-API-Key` header, supplied by ciopulse at onboarding. HTTPS only.

## Payload

Expand All @@ -29,7 +30,7 @@ Content-Type: application/json
| `started_at`, `ended_at` | ✔ | ISO 8601 with timezone |
| `turns[]` | ✔ | Ordered, chronological. Each: `role` (`"user"` \| `"agent"` \| `"system"`), `text`, `ts` (ISO 8601 with timezone), and optionally `actor` (below) |
| `turns[].actor` | – | **New in v0.3.** `"bot"` when the turn was produced by an automated agent, `"human"` when by a person; omit when unknown. Only meaningful when `role` is `"agent"`. Receivers that need the split and find it absent treat turns before the first `escalation_to_human` event as bot and later ones as human |
| `channel` | – | `"chat"` (default) or **`"voice"`** (new in v0.2). Voice means the turns are text produced by speech-to-text; ciopulse reads them exactly as chat. Voice requires `contract_version` `"0.2"`. Audio is never sent |
| `channel` | – | `"chat"` (default) or **`"voice"`** (new in v0.2). Voice means the turns are text produced by speech-to-text; ciopulse reads them exactly as chat. Voice requires `contract_version` `"0.2"` or later. Audio is never sent |
| `outcome` | – | Your call: `"contained"` \| `"escalated"` \| `"abandoned"` \| `"unknown"` |
| `events[]` | – | e.g. `{"type": "escalation_to_human", "ts": "…"}` |
| `platform_signals` | – | **New in v0.2.** Measurements your platform already computed about this conversation. See below. Omit the block entirely if you have none |
Expand Down Expand Up @@ -99,7 +100,7 @@ Unknown fields inside `platform_signals` are ignored, not rejected.

- **Response:** `202 Accepted` + a receipt ID. Validation is synchronous (auth, schema, size); everything else is asynchronous. Typical response < 500 ms.
- **Duplicates / retries:** re-POSTing the same `session_id` within 30 days replaces the earlier submission — retrying is always safe.
- **Limits:** payload ≤ 1 MB · ≤ 500 turns · `metadata` ≤ 2 KB · per-key rate limit. Errors: `400` (field-level detail), `401`, `413`, `429` (with `Retry-After`).
- **Limits:** payload ≤ 1 MB · ≤ 500 turns · `metadata` ≤ 2 KB. Errors: `400` (field-level detail), `401`, `413`. Rate limiting is not currently applied; senders should still treat any `429` as retryable with backoff.
- **When to send:** once, when the conversation ends. Retry on `5xx` with backoff if convenient — or don't; a missed transcript is acceptable by design.
- **Voice transcripts** read differently from chat (disfluencies, transcription errors). Send them as they are; do not clean them up. ciopulse's reader accounts for speech-to-text artefacts.

Expand Down
52 changes: 52 additions & 0 deletions tests/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,3 +372,55 @@ def test_mock_rejects_bad_v03_fields(aws, env, mock_ingest):
assert exc.code == 400
fields = {p["field"] for p in json.loads(exc.read())["problems"]}
assert fields == {"conversation_id", "turns[0].actor"}


# ----------------------------------------------------------------------------- mock receiver: v4 corrections

def _post_to_mock(url, body, headers):
import urllib.request, urllib.error
req = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST",
headers={"Content-Type": "application/json", **headers})
try:
with urllib.request.urlopen(req) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read())


def _valid_payload(**extra):
p = {"contract_version": "0.3", "session_id": "s-v4", "agent": {"id": "a", "version": "1"},
"started_at": "2026-09-22T00:00:00+00:00", "ended_at": "2026-09-22T00:01:00+00:00",
"turns": [{"role": "user", "text": "hi", "ts": "2026-09-22T00:00:01+00:00"}]}
p.update(extra)
return p


def test_mock_rejects_http_basic(mock_ingest):
import base64
basic = "Basic " + base64.b64encode(b"portal:test-key-123").decode()
status, body = _post_to_mock(mock_ingest["url"], _valid_payload(), {"Authorization": basic})
assert status == 401 and body["hint"] == "send X-API-Key"
status, _ = _post_to_mock(mock_ingest["url"], _valid_payload(), {"X-API-Key": "test-key-123"})
assert status == 202


def test_mock_requires_ts_on_escalation_event(mock_ingest):
key = {"X-API-Key": "test-key-123"}
status, body = _post_to_mock(mock_ingest["url"], _valid_payload(events=[{"type": "escalation_to_human"}]), key)
assert status == 400
[problem] = body["problems"]
assert problem["field"] == "events[0].ts" and "turns[].actor is absent" in problem["error"]
status, body = _post_to_mock(mock_ingest["url"],
_valid_payload(events=[{"type": "escalation_to_human", "ts": "yesterday"}]), key)
assert status == 400 and body["problems"][0]["field"] == "events[0].ts"
status, _ = _post_to_mock(mock_ingest["url"],
_valid_payload(events=[{"type": "escalation_to_human", "ts": "2026-09-22T00:00:30+00:00"}]), key)
assert status == 202


def test_mock_other_events_still_need_only_type(mock_ingest):
key = {"X-API-Key": "test-key-123"}
status, _ = _post_to_mock(mock_ingest["url"], _valid_payload(events=[{"type": "custom_marker"}]), key)
assert status == 202
status, body = _post_to_mock(mock_ingest["url"], _valid_payload(events=[{"type": "custom_marker", "ts": "soon"}]), key)
assert status == 400 and body["problems"][0]["field"] == "events[0].ts" # a ts, if given, must be ISO
26 changes: 13 additions & 13 deletions tools/mock-receiver/mock_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@
GET /received what it has accepted, newest first
GET /health

Auth: X-API-Key: <key> or HTTP Basic (portal code : key)
Auth: X-API-Key: <key>

It enforces the contract strictly and returns field-level errors, so a 202 here
means the payload really is conformant — that is the whole point of it.
"""
import argparse, base64, json, re, uuid, sys
import argparse, json, re, uuid, sys
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
Expand All @@ -33,6 +33,9 @@
OUTCOMES = {"contained", "escalated", "abandoned", "unknown"}
ISO = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)$")

# "seen" is keyed on session_id alone because this mock serves a single sender. The real endpoint keys
# idempotency on (tenant, session_id): two customers can legitimately send the same session_id. Do not
# port this single-key logic into the production endpoint.
STATE = {"key": "testkey123", "log": Path("received.jsonl"), "seen": {}}


Expand Down Expand Up @@ -136,6 +139,12 @@ def bad(f, m):
for i, x in enumerate(ev):
if not isinstance(x, dict) or "type" not in x:
bad(f"events[{i}]", "each event needs a type")
continue
if x["type"] == "escalation_to_human" and "ts" not in x:
bad(f"events[{i}].ts", "escalation_to_human requires ts — the bot/human turn split "
"falls back to this timestamp when turns[].actor is absent")
elif "ts" in x and not iso(x["ts"]):
bad(f"events[{i}].ts", "must be ISO 8601 with timezone")

return e

Expand All @@ -155,16 +164,7 @@ def _send(self, code, body):
self.wfile.write(b)

def _authed(self):
if self.headers.get("X-API-Key") == STATE["key"]:
return True
a = self.headers.get("Authorization", "")
if a.startswith("Basic "):
try:
_, _, pw = base64.b64decode(a[6:]).decode().partition(":")
return pw == STATE["key"]
except Exception:
return False
return False
return self.headers.get("X-API-Key") == STATE["key"]

def do_GET(self):
if self.path == "/health":
Expand All @@ -182,7 +182,7 @@ def do_POST(self):
"hint": "POST /api/v5/ai-agent/transcripts"})
if not self._authed():
return self._send(401, {"error": "unauthorized",
"hint": "send X-API-Key, or HTTP Basic with the key as the password"})
"hint": "send X-API-Key"})

n = int(self.headers.get("Content-Length") or 0)
if n > MAX_BYTES:
Expand Down
Loading