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
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ The full shapes live in `chat/domain.py`. The words to know:
- `GET /healthz`: the process is up. It does not probe the DSS.

## What this service depends on
- **DSS**, `POST /v1/turns` over SSE, at `EXPERIENCE_API_DSS_BASE_URL`.
`EXPERIENCE_API_DSS_MODE=fake` (the default) answers from memory instead.
- **DSS**, `POST /v1/turns` over SSE, at `EXPERIENCE_API_DSS_BASE_URL`. Always
the real one: the fake DSS lives in `tests/support/` and only tests use it.
- No database, no messaging.

## Conventions
Expand All @@ -68,16 +68,16 @@ src/experience_api/
├── ports.py # Protocols for what the feature needs from outside
└── adapters/
├── http/ # inbound: routes, wire schemas (camelCase), mapping
└── <name>/ # outbound: implements a port (dss/ today, with a fake)
└── <name>/ # outbound: implements a port (dss/ today)
```

## Testing
TDD: write the failing test first. Every commit is green.

| Tier | Path | What | Uses |
|---|---|---|---|
| Unit | `tests/unit/<feature>/` | mapping, the service, SSE framing | the fake DSS; fixed clock and ids |
| API | `tests/api/` | the whole app through httpx's ASGI transport | the fake DSS |
| Unit | `tests/unit/<feature>/` | mapping, the service, SSE framing | the fake DSS (`tests/support/fake_dss.py`); fixed clock and ids |
| API | `tests/api/` | the whole app through httpx's ASGI transport | the fake DSS, passed to `create_app(dss=...)` |
| Integration | `tests/integration/` | `HttpDssClient` against a local HTTP server | pytest-httpserver |
| E2E | `tests/e2e/` | one turn against a real DSS | marker `e2e`, not run by default |
| Boundaries | `tests/test_boundaries.py` | the dependency rule | the AST |
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ All notable changes to this project are recorded here, in the format of

## [Unreleased]

### Removed
- `EXPERIENCE_API_DSS_MODE` and the fake DSS mode. The API always calls the DSS; the fake is now a test double only.

### Added
- A filled form sent back as `content` in place of `query`, passed to the DSS; a `form_error` item in the answer hands it back.
- A `form` item in the answer: a form the DSS offers reaches the client instead of being dropped.
- CI publishes the image to `ghcr.io/openagrinet/experience-api` from main and release tags.
- `EXPERIENCE_API_DSS_MODE=http` calls the real DSS: the §7 request, its event stream read leniently.
Expand Down
17 changes: 6 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,19 @@ The API listens on `http://localhost:8078`.
| `GET /healthz` | `{"status": "ok"}` when the process is up. For Docker and the proxy |
| `GET /docs` | The interactive OpenAPI page |

By default a fake DSS answers every turn, so the API runs with nothing else
installed. To call a real one:

```bash
EXPERIENCE_API_DSS_MODE=http uv run uvicorn --factory experience_api.app:create_app --port 8078
```
Every turn goes to the DSS at `EXPERIENCE_API_DSS_BASE_URL`, so run a DSS
first. There is no stand-in: with no DSS, a turn fails.

| Variable | Default | What |
|---|---|---|
| `EXPERIENCE_API_SENDER_ID` | none, required | The network participant this deployment serves. Sent to the DSS as `senderId` |
| `EXPERIENCE_API_DSS_MODE` | `fake` | `fake`, or `http` to call the DSS |
| `EXPERIENCE_API_DSS_BASE_URL` | `http://localhost:8077` | Where the DSS listens |
| `EXPERIENCE_API_CHANNEL` | `web` | The channel for a chat whose client sends none |
| `EXPERIENCE_API_MAX_CHARACTERS` | `1200` | The longest answer to ask the DSS for |

Each can also be set in `.env`; a real environment variable wins over it.

A missing sender id, or a bad mode, channel or length, stops the API from
A missing sender id, or a bad channel or length, stops the API from
starting; a wrong DSS URL
fails the first turn, like a DSS that is down. Errors from the DSS itself (it being
down, busy, or refusing the request) come back as a plain `500` for now.
Expand All @@ -60,9 +55,9 @@ docker compose up --build # add -d to run it in the background
docker compose down
```

Same port, same fake DSS, and the sender id from `.env`. `EXPERIENCE_API_DSS_MODE=http docker compose up
--build` calls a DSS on the host's port 8077; the other variables pass through
too. Set `EXPERIENCE_API_HOST_PORT` to publish on another port. The container
Same port, and the sender id from `.env`. It calls a DSS on the host's port
8077; set `EXPERIENCE_API_DSS_BASE_URL` for another. The other variables pass
through too. Set `EXPERIENCE_API_HOST_PORT` to publish on another port. The container
reports healthy once `/healthz` answers.

### The published image
Expand Down
4 changes: 1 addition & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# Run: docker compose up --build
# The API on http://localhost:8078, answered by the fake DSS. For a real one:
# EXPERIENCE_API_DSS_MODE=http docker compose up --build
# The API on http://localhost:8078, calling the DSS at EXPERIENCE_API_DSS_BASE_URL.
services:
experience-api:
build:
Expand All @@ -9,7 +8,6 @@ services:
environment:
# No default: Compose reads it from `.env` (copy `.env.example`).
EXPERIENCE_API_SENDER_ID: ${EXPERIENCE_API_SENDER_ID}
EXPERIENCE_API_DSS_MODE: ${EXPERIENCE_API_DSS_MODE:-fake}
# Default: a DSS on this machine's port 8077, reached from the container.
EXPERIENCE_API_DSS_BASE_URL: ${EXPERIENCE_API_DSS_BASE_URL:-http://host.docker.internal:8077}
EXPERIENCE_API_CHANNEL: ${EXPERIENCE_API_CHANNEL:-web}
Expand Down
18 changes: 9 additions & 9 deletions docs/ADR/0001-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ HTTP, JSON or the services it calls.
| Service | `chat/service.py` — `ChatService` | the feature's rules |
| Domain | `chat/domain.py` | plain types and errors |
| Port | `chat/ports.py` — `DssClient` | what the feature needs from outside, in its own terms |
| Outbound adapter | `chat/adapters/dss/` | the HTTP client and a fake, each fulfilling the port |
| Outbound adapter | `chat/adapters/dss/` | the HTTP client, fulfilling the port. A fake fulfils it in tests *(amended)* |

The service does not know it is called over HTTP.

Expand Down Expand Up @@ -66,7 +66,7 @@ experience-api/
├── pyproject.toml # uv; fastapi, uvicorn, httpx, pydantic-settings
├── uv.lock
├── Dockerfile
├── docker-compose.yml # the API, in fake-DSS mode by default (§7)
├── docker-compose.yml # the API, calling the DSS
├── README.md
├── CLAUDE.md # layout, stack, test tiers
├── CONVENTIONS.md # naming, commits, PRs
Expand Down Expand Up @@ -98,7 +98,6 @@ experience-api/
│ │ └── errors.py # exception handlers: chat errors → code + status (§6.1)
│ └── dss/ # OUTBOUND — contract §7; implements ports.DssClient
│ ├── client.py # HttpDssClient: httpx stream to POST /v1/turns
│ ├── fake.py # FakeDssClient: canned scenarios (§7)
│ ├── schemas.py # the DSS wire shapes we send and read
│ └── mapping.py # ChatTurn → DSS request; DSS frames → domain events
│
Expand Down Expand Up @@ -372,22 +371,22 @@ A feature with many settings can later get its own nested settings class.

| Env var | Default | Contract |
|---|---|---|
| `EXPERIENCE_API_DSS_MODE` | `fake` | `http` or `fake` (§7) |
| `EXPERIENCE_API_DSS_BASE_URL` | `http://localhost:8077` | where the DSS listens |
| `EXPERIENCE_API_CHANNEL` | `web` | §7 `attributes.channel` |
| `EXPERIENCE_API_MAX_CHARACTERS` | `1200` | §7 `attributes.response.maxCharacters` |
| `EXPERIENCE_API_ACCEPT_TIMEOUT_SECONDS` | `30` | §6.3 — DSS headers and first event |
| `EXPERIENCE_API_TURN_TIMEOUT_SECONDS` | `120` | §6.3 |
| `EXPERIENCE_API_FAKE_SCENARIO` | `answered` | which canned scenario the fake plays (§7) |
| `EXPERIENCE_API_FAKE_DELAY_SECONDS` | `0` | silence before the fake's first frame |

The default is `fake` so the API runs with nothing else installed. A
deployment sets `http`.
*(Amended.)* There is no DSS mode: the running API always calls the DSS (§7).

---

## 7. The fake DSS

*(Amended.)* The fake is a test double only. It lives in
`tests/support/fake_dss.py`, and tests pass it to `create_app(dss=...)`. The
running API always calls the real DSS, so no reply a person sees can be a
canned one. Purposes 2 and 3 below, and the scenarios, no longer apply.

`FakeDssClient` yields canned `DssEvent`s, one scenario per outcome, written
in `fake.py` itself. It serves three purposes:

Expand Down Expand Up @@ -483,3 +482,4 @@ of the contract shows up at the seam. Each phase is one or two PRs.
| Adapters may import only their feature's `domain` and `ports` | The inbound `adapters/http/` may also import its `service` | The route in §5.1 takes a `ChatService`; calling the service is an inbound adapter's job |
| `DssClient` has `aclose()`, called by the lifespan | The port has only `stream_turn`. `HttpDssClient` is an async context manager, entered by the lifespan | Closing a pool is lifecycle, not something chat needs. Whoever builds a client closes it |
| Timeouts in step 5 | After `HttpDssClient`, with httpx timeouts as the interim safety net | Not needed for the first cut |
| A fake DSS mode, the default, for running the API with no DSS (§6, §7) | No mode. The fake is a test double in `tests/support/`; the app always calls the DSS | A canned reply looked like a real one. A form sent to the default API was never filed, and nothing said so |
93 changes: 83 additions & 10 deletions docs/api-contracts/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ Rules for working in parallel:

- **§3–§6 are the contract.** Neither side changes its behaviour away from
them. A change is made **here first**, then in code.
- The client builds against its own stubs, with no running API. The API builds
against a fake DSS, with no running client.
- Each side calls the real one: the client the API, the API the DSS. Neither
ships a stand-in *(amended)*. The API's tests use a fake DSS.

---

Expand Down Expand Up @@ -189,8 +189,9 @@ Example in the parked fixtures: `request/follow-up.json`
| Field | Type | Required | Rule |
|---|---|:--:|---|
| `sessionId` | string, UUID | Yes | |
| `messageId` | string, UUID | Yes | Id of the message in `query` |
| `query` | string | Yes | Not empty after trimming |
| `messageId` | string, UUID | Yes | Id of the message in `query`, or of the form in `content` |
| `query` | string | One of `query` or `content` | A typed question. Not empty after trimming |
| `content` | array of one `form` | One of `query` or `content` | A filled form sent back (§4.2). Exactly one item |
| `history` | array | Yes | May be empty. Oldest first. Alternating is not required. Too long → `413` (§6.2) |
| `history[].role` | `user` \| `assistant` | Yes | |
| `history[].text` | string | Yes | Not empty after trimming |
Expand All @@ -201,6 +202,9 @@ Example in the parked fixtures: `request/follow-up.json`
| `location.longitude` | number | With `location` | −180 to 180 |
| `channel` | `web` | No | Where the chat comes from. Missing or `null` → the deployment's default. Any other value → `422` |

A request carries **exactly one** of `query` and `content`. Both, or neither,
is a `422`: the DSS takes one kind of message per turn.

Why `query` is separate from `history`: the current message is explicit rather
than "the last user entry". The DSS treats the last `user` message as the
question; the API builds that ordering, so the client cannot get it wrong.
Expand Down Expand Up @@ -231,6 +235,44 @@ rejection therefore always means an API bug, and is reported as
- When stubs are on, nothing reaches the API, so stub replies never enter a
real request.

### 4.2 Sending a form back

When the farmer fills in a `form` (§5.2) and confirms, the client sends it as
`content` in place of `query`:

```json
{
"sessionId": "68a3872f-3f0d-4cf6-99a3-a350132a0080",
"messageId": "5b0e1c2a-7d4f-4a8e-9c3b-1f2e3d4c5b6a",
"content": [
{
"type": "form",
"formId": "f-1",
"context": { "scheme": "PMFBY", "category": "Grievance", "action": "support" },
"submissionId": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
"fields": [
{ "name": "applicantPhone", "value": "9876543210" },
{ "name": "season", "value": "" }
]
}
],
"history": [],
"language": { "source": "en", "target": "en" }
}
```

| Field | Type | Required | Rule |
|---|---|:--:|---|
| `content[].type` | `form` | Yes | |
| `content[].formId` | string | Yes | As the `form` item gave it. Not empty |
| `content[].context` | object of strings | Yes | As the `form` item gave it, unchanged. The API does not read it |
| `content[].submissionId` | string, UUID | Yes | Made by the client, new for every submit, including a resubmit after errors |
| `content[].fields[].name` | string | Yes | Not empty |
| `content[].fields[].value` | string | Yes | May be empty: an optional field left blank |

The API checks the shape only. The DSS checks the values against the form's
rules and hands back what is wrong as a `form_error` item (§5.2).

---

## 5. Response — Experience API to client
Expand Down Expand Up @@ -316,10 +358,11 @@ Example in the parked fixtures: `response/answered.json`
| `outcome.status` | Yes | `answered`, `partially_answered`, `rejected`, `no_match`, `requires_input`, `unavailable`. For logs; the client does not branch on it (§5.3) |
| `outcome.cause` | Yes, may be `null` | Open set, passed through. Treated as an opaque string |
| `content` | Yes, may be `[]` | In order. Empty only when `error` is present |
| `content[].type` | Yes | `text`, `refusal` or `form` |
| `content[].type` | Yes | `text`, `refusal`, `form` or `form_error` |
| `content[].text` | `text` and `refusal` | Markdown, as today |
| `content[].citations` | `text` only; may be `[]` | `start`/`end` are Unicode code-point offsets into `text` |
| `content[].formId`, `context`, `fields` | `form` only | An empty form to fill in. Shape below |
| `content[].formId`, `context`, `submissionId`, `fields` | `form_error` only | A sent form handed back. Shape below |
| `sources` | Yes, may be `[]` | `{ id, name, url? }`. What citations point at |
| `error` | No | Present only when the turn failed. `Error` shape (§6) |

Expand Down Expand Up @@ -349,8 +392,36 @@ A `form` item asks the farmer for details, such as for a grievance:
| `fields[].pattern`, `minLength`, `maxLength` | No | Present only when the field has the rule |
| `fields[].options` | No | `{ value, label }`. Present only for a `select` |

Sending a filled form back is not in this contract yet. The answer still
carries the form, so the client can draw it.
The client sends a filled form back as `content` (§4.2).

A `form_error` item hands a sent form back. **It means the form is still the
farmer's**: the client keeps it open and lets them send it again.

```json
{
"type": "form_error",
"formId": "f-1",
"context": { "scheme": "PMFBY", "category": "Grievance", "action": "support" },
"submissionId": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
"fields": [
{ "name": "applicantPhone", "label": "Phone", "error": "Enter a 10-digit phone number" }
]
}
```

| Field | Required | Notes |
|---|:--:|---|
| `formId`, `context` | Yes | The form's, as sent |
| `submissionId` | Yes | The submission this answers |
| `fields` | Yes, may be `[]` | `{ name, label, error }`: what to fix, in the user's language. **Empty** when the values were fine but the office could not take the form; `outcome` and `error` then say why |

How a sent form ends, by what the answer carries:

| `outcome.status` | `content` | Meaning |
|---|---|---|
| `requires_input` | text and a `form_error` with fields | Fix those fields and send again |
| `unavailable` | text and a `form_error` with no fields, plus `error` | Not filed. The form stays; `error.retryable` says if trying later can help |
| `answered` | text | Filed. The text names the ticket |

### 5.3 How the client renders each outcome

Expand All @@ -370,7 +441,8 @@ through untouched.

The client branches on exactly two things:

1. **`content[].type`** — `text` or `refusal` for styling; `form` to draw a form.
1. **`content[].type`** — `text` or `refusal` for styling; `form` to draw a
form; `form_error` to reopen a sent form.
2. **`error`** — present means the turn failed: show Retry when
`error.retryable` is true, and keep the turn out of history.

Expand Down Expand Up @@ -520,7 +592,7 @@ is what the API sends for `request/follow-up.json`.
| `context.messageId` | Client `messageId` |
| `context.version` | Omitted. It is a DSS response field |
| `context.senderId` | API config (`EXPERIENCE_API_SENDER_ID`): the network participant this deployment serves. Always sent |
| `message.input` | `history` as sent, then `query` as the last `user` item. Each text becomes `[{ "type": "text", "text": … }]` |
| `message.input` | `history` as sent, then `query` as the last `user` item. Each text becomes `[{ "type": "text", "text": … }]`. A form in `content` is the last `user` item instead: `[{ "type": "form", formId, context, submissionId, fields }]`, unchanged |
| `attributes.channel` | Client `channel`; when missing, API config (`EXPERIENCE_API_CHANNEL`) |
| `attributes.sourceLanguage` / `targetLanguage` | Client `language.source` / `.target` |
| `attributes.location.geometry.coordinates` | `[longitude, latitude]` — note the swap |
Expand Down Expand Up @@ -563,6 +635,7 @@ Reshaping a terminal frame into `FinalAnswer`:
| `content[].annotations[]` (`url_citation`) | `citations[]`: `sourceId`, `startIndex` → `start`, `endIndex` → `end`. `url`/`sourceName` dropped — they are on `sources` |
| `content[]` `refusal` | `{ type: "refusal", text }` |
| `content[]` `form` | `{ type: "form", formId, context, fields }`, unchanged. A form without `formId` or `context` fails the frame |
| `content[]` `form_error` | `{ type: "form_error", formId, context, submissionId, fields }`, unchanged. One without `formId`, `context` or `submissionId` fails the frame |
| `sources[]` | `{ id, name, url }`. `kind` dropped |
| `error` (`code`, `message`, `retryable`, `retryAfterSeconds`) | `error`, same fields |

Expand Down Expand Up @@ -591,7 +664,7 @@ Sample DSS streams, one per outcome: `dss-stream/` in the parked fixtures

1. **The DSS's prose and code disagree in places.** The live code is what
counts: `transactionId` is required, `messageId` optional, only `text`
content is accepted (no `image` yet), unknown fields are rejected, and an
and `form` content are accepted (no `image` yet), unknown fields are rejected, and an
`unavailable` turn ends with `turn.failed`, not `turn.completed`. ADR-0004
and `orchestration/envelope.py` describe an older request shape — worth
flagging to the DSS team.
Expand Down
3 changes: 1 addition & 2 deletions docs/spec/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Chat is lenient here. New DSS fields, events and content types are ignored, so
a DSS release cannot break a turn.

### Without a DSS
With `DSS_MODE=fake`, chat plays one canned weather answer for every turn.
There is no stand-in. With no DSS, a turn fails. Only tests use the fake DSS.

## Key flows
A streamed turn: client → chat → DSS, then `started`, `delta`…, `completed`
Expand All @@ -55,7 +55,6 @@ can echo the user's words.
## Configuration and limits
| Setting | Default | Notes |
|---|---|---|
| `EXPERIENCE_API_DSS_MODE` | `fake` | `fake` or `http` |
| `EXPERIENCE_API_DSS_BASE_URL` | `http://localhost:8077` | Checked on the first turn, not at start |
| `EXPERIENCE_API_CHANNEL` | `web` | Sent to the DSS |
| `EXPERIENCE_API_MAX_CHARACTERS` | `1200` | Sent to the DSS |
Expand Down
3 changes: 0 additions & 3 deletions src/experience_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
from fastapi import APIRouter, FastAPI

from experience_api.chat.adapters.dss.client import HttpDssClient
from experience_api.chat.adapters.dss.fake import FakeDssClient
from experience_api.chat.adapters.http.routes import router as chat_router
from experience_api.chat.domain import Channel
from experience_api.chat.ports import DssClient
Expand Down Expand Up @@ -72,8 +71,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:


async def _dss_client(settings: Settings, stack: AsyncExitStack) -> DssClient:
if settings.dss_mode == "fake":
return FakeDssClient()
return await stack.enter_async_context(
HttpDssClient(
base_url=settings.dss_base_url,
Expand Down
Loading
Loading