From b912c2258db61b975506e766689c49dca456cd0b Mon Sep 17 00:00:00 2001 From: bhavesh-suvalaka Date: Wed, 21 Oct 2026 14:43:29 +0530 Subject: [PATCH 1/9] docs: add the spec for sender id and channel [#192] Co-Authored-By: Claude Opus 5.5 --- docs/.agent/specs/192-sender-and-channel.md | 121 ++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/.agent/specs/192-sender-and-channel.md diff --git a/docs/.agent/specs/192-sender-and-channel.md b/docs/.agent/specs/192-sender-and-channel.md new file mode 100644 index 0000000..46d3047 --- /dev/null +++ b/docs/.agent/specs/192-sender-and-channel.md @@ -0,0 +1,121 @@ +# Spec: Tell the DSS which network participant and channel each chat comes from (Experience API) + +Story: https://github.com/OpenAgriNet/engineering-tracker/issues/192 +PRD: NoN Product Brief (`NoN_Product_Brief.docx`, shared outside the repo) +Sibling spec: `decision-support-system/docs/.agent/specs/192-sender-id.md` + +## Architecture / approach + +Two independent changes, both at the edges of the `chat` feature. + +**Sender id.** Set once per deployment, like the DSS URL. It flows +`Settings.sender_id` → `app.py` → `HttpDssClient(sender_id=...)` → +`to_dss_request(...)` → `context.senderId`. The name matches the network's own +`/select` context, so #217 can sign it without a rename. `FakeDssClient` ignores it. + +**Channel.** Moves from "fixed per client object" to "part of each turn". +- `Channel(StrEnum)` in `chat/domain.py`, one member: `WEB = "web"`. The enum is + the allowlist; adding a channel is a code change. +- `ChatTurn.channel: Channel`. +- The client may send `channel`. Pydantic converts it in the HTTP schema. An + unknown value is a `422`. +- The default stays config: `EXPERIENCE_API_CHANNEL` (a plain `str`, since + `settings.py` may import only pydantic). `app.py` converts it with + `Channel(settings.channel)` at startup, so a typo stops the app, and keeps the + result on `app.state.default_channel`. +- `to_chat_turn(body, default_channel=...)` fills it in when the body has none. +- `HttpDssClient` loses its `channel` argument; `to_dss_request` reads + `turn.channel`. + +**Rejected alternatives** +- *Sender id as an HTTP header* (`client-id`, `X-Tenant-Id`): it would not be + covered by a body signature in #217, and "client id" clashes with OAuth. +- *Sender id optional on the Experience API*: a Type B deployment that forgets it + would silently be treated as the host network once preference lands. +- *Channel allowlist in config*: a new channel needs code anyway (the DSS shapes + answers per channel), so the enum is the allowlist. +- *Converting the config default in the route*: a typo would surface as a `500` + on the first turn without a channel, not at startup. +- *`maxCharacters` per channel*: only `web` exists; one value per deployment stays. + +## Data model + +No data model changes. The service stores nothing. + +## API contracts + +`docs/api-contracts/api-contract.md` changes first; the web client builds +against it. +- §4 Request: new optional `channel`, enum `"web"`. Missing means the + deployment default. +- §6.1: no new code. An unknown `channel` is the existing `422` validation error. +- §7 mapping table: `context.senderId` comes from API config; + `attributes.channel` comes from the request, else API config. +- §8.3: one amendment line for each change. + +Outbound to the DSS: `context.senderId` (string). The DSS ignores fields it does +not know, so either service can ship first. + +Config: +- `EXPERIENCE_API_SENDER_ID`: required in every mode; must not be blank. No + format check; the network registry owns the format. No default in code. +- `EXPERIENCE_API_CHANNEL`: unchanged name, now only `web` starts. + +Local runs: +- `Settings` reads `.env` as well (`env_file=".env"`). Real environment + variables win over it, so deployments are unaffected. +- A committed `.env.example` holds `EXPERIENCE_API_SENDER_ID=experience-api.local`. +- `docker-compose.yml` passes `EXPERIENCE_API_SENDER_ID: ${EXPERIENCE_API_SENDER_ID}`; + Compose fills it from `.env`. +- `.dockerignore` excludes `.env`, so an image never carries a local sender id. +- The run command does not change. The `app.py` docstring no longer says the app + boots with nothing configured; it needs the sender id. + +## Edge cases + +- Client sends no `channel`, or `null`: the turn uses the deployment default + (`web`). `null` means missing, as it does for `location`. +- Client sends `"web"`: used as sent. +- Client sends `"voice"`, `"WEB"` or `""`: `422`; nothing reaches the DSS. + +## Failure modes + +- `EXPERIENCE_API_SENDER_ID` unset or blank: the app does not start; the error + names the setting. +- `EXPERIENCE_API_CHANNEL` not a `Channel` value (for example `voice`, or a typo): + the app does not start. A deployment set to `whatsapp`, `voice` or `sms` today + will fail on deploy. + +## Implementation tasks + +1. Contract: document `channel` and `senderId` + - [ ] Given the contract, when a reader looks at §4, then `channel` is listed as optional with the value `web` and the default rule. + - [ ] Given the contract, when a reader looks at §7, then `context.senderId` and the new source of `attributes.channel` are listed, with an §8.3 amendment. +2. Channel on the turn + - [ ] Given a `ChatTurn` with `Channel.WEB`, when `to_dss_request` builds the body, then `attributes.channel` is `"web"`. + - [ ] Given `HttpDssClient`, when it is built, then it takes no `channel` argument. +3. Channel from the client, default from config + - [ ] Given the client sends no channel, when the chat is sent, then the DSS request carries the deployment default. + - [ ] Given the client sends `"web"`, when the chat is sent, then the DSS request carries `"web"`. + - [ ] Given the client sends a channel not in the enum, when the chat is sent, then the API answers `422` and the DSS is not called. + - [ ] Given `EXPERIENCE_API_CHANNEL` is not a `Channel` value, when the app starts, then startup fails. +4. Sender id to the DSS + - [ ] Given a sender id is set, when a chat is sent, then the DSS request's `context.senderId` equals it. + - [ ] Given no or a blank sender id, in any DSS mode, when the app starts, then startup fails naming the setting. + - [ ] Given a `.env` with the sender id and no environment variable, when the app starts, then it uses the `.env` value. + - [ ] Given both are set, when the app starts, then the environment variable wins. + - [ ] Given the repo, when a reader looks, then `.env.example` holds a local sender id, `docker-compose.yml` passes it through, and `.dockerignore` excludes `.env`. + +## Test strategy + +- Unit (`tests/unit/chat/`): `to_dss_request` puts `senderId` and `channel` in the + body; `to_chat_turn` applies the default; `Settings` rejects a missing sender id, + reads `.env`, and lets the environment win (using `_env_file` pointed at a temp + file, so a developer's own `.env` never affects tests). Test settings + (`tests/conftest.py`) set a sender id. +- API (`tests/api/`): no channel, `web`, and an unknown channel (`422`, fake DSS + not called); startup fails on a bad default. +- Integration (`tests/integration/`): `HttpDssClient` against pytest-httpserver; + the received body has `context.senderId` and `attributes.channel`. +- Boundaries: `test_boundaries.py` stays green; `settings.py` does not import the + domain. From 6472f65b10b6513054f294cefc9d7af276946cd8 Mon Sep 17 00:00:00 2001 From: bhavesh-suvalaka Date: Wed, 21 Oct 2026 14:43:47 +0530 Subject: [PATCH 2/9] docs: add channel and senderId to the contract [#192] Co-Authored-By: Claude Opus 5.5 --- docs/api-contracts/api-contract.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/api-contracts/api-contract.md b/docs/api-contracts/api-contract.md index c6c59a7..43648e5 100644 --- a/docs/api-contracts/api-contract.md +++ b/docs/api-contracts/api-contract.md @@ -199,6 +199,7 @@ Example in the parked fixtures: `request/follow-up.json` | `location` | object | No | Omitted when the browser prompt is denied | | `location.latitude` | number | With `location` | −90 to 90 | | `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` | 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 @@ -207,7 +208,7 @@ question; the API builds that ordering, so the client cannot get it wrong. Why `latitude` / `longitude` and not GeoJSON: it is what the browser gives, and the `[lon, lat]` order is a classic bug. The API does the conversion once. -Unknown fields are rejected (`422`), matching the DSS's own rule. So is a +Unknown fields are rejected (`422`). So is a `Content-Type` other than `application/json`. **Anything the DSS would reject, the API rejects first**, with `422`. A DSS @@ -460,7 +461,8 @@ is what the API sends for `request/follow-up.json`. "timestamp": "2026-09-23T08:00:00Z", "sessionId": "68a3872f-3f0d-4cf6-99a3-a350132a0080", "transactionId": "3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b", - "messageId": "1ab38d6c-6fdb-4849-8ea1-da5e80a8687c" + "messageId": "1ab38d6c-6fdb-4849-8ea1-da5e80a8687c", + "senderId": "exp-karnataka.da.gov.in" }, "message": { "input": [ @@ -490,8 +492,9 @@ is what the API sends for `request/follow-up.json`. | `context.transactionId` | Minted by the API, one per request | | `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": … }]` | -| `attributes.channel` | API config. `web` for this client | +| `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 | | `attributes.location.region` / `.area` | Omitted, by decision (§8.1) | @@ -594,6 +597,8 @@ Sample DSS streams, one per outcome: `dss-stream/` in the parked fixtures | §6.1 | `400 malformed_request`, `422 invalid_request`, `404`, `405` in our shape | FastAPI's defaults in the first build | Keep the first cut thin. Decide at peer review | | §6.3 | Both timeouts | Deferred until after the real DSS client | Not needed for the first cut | | §0 | Links to the architecture doc and the fixtures | Links to ADR-0001; fixtures named, not linked | They live in another repo now | +| §4 | No `channel`; the channel was fixed per deployment | Optional `channel`, `web` only; missing means the deployment default | One API can serve more than one channel (#192) | +| §7 | No sender in `context` | `context.senderId` from API config | The DSS can tell network participants apart (#192) | ## 9. What changes in this client *(web client only)* From 35a957a26c2d8e904427a7402944b0a2559a275b Mon Sep 17 00:00:00 2001 From: bhavesh-suvalaka Date: Wed, 21 Oct 2026 14:49:02 +0530 Subject: [PATCH 3/9] refactor: carry the channel on each chat turn [#192] Co-Authored-By: Claude Opus 5.5 --- src/experience_api/app.py | 1 - src/experience_api/chat/adapters/dss/client.py | 3 --- src/experience_api/chat/adapters/dss/mapping.py | 3 +-- src/experience_api/chat/adapters/http/mapping.py | 2 ++ src/experience_api/chat/domain.py | 10 ++++++++++ tests/integration/test_http_dss_client.py | 3 +-- tests/unit/chat/test_dss_request.py | 8 +++++++- tests/unit/chat/test_fake_dss.py | 2 ++ tests/unit/chat/test_http_mapping.py | 3 ++- 9 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/experience_api/app.py b/src/experience_api/app.py index 0f8b08d..d4d54d9 100644 --- a/src/experience_api/app.py +++ b/src/experience_api/app.py @@ -70,7 +70,6 @@ async def _dss_client(settings: Settings, stack: AsyncExitStack) -> DssClient: return await stack.enter_async_context( HttpDssClient( base_url=settings.dss_base_url, - channel=settings.channel, max_characters=settings.max_characters, ) ) diff --git a/src/experience_api/chat/adapters/dss/client.py b/src/experience_api/chat/adapters/dss/client.py index 2109264..c3cd3ab 100644 --- a/src/experience_api/chat/adapters/dss/client.py +++ b/src/experience_api/chat/adapters/dss/client.py @@ -29,12 +29,10 @@ def __init__( self, *, base_url: str, - channel: str, max_characters: int, clock: Callable[[], datetime] = lambda: datetime.now(UTC), ) -> None: self._http = httpx.AsyncClient(base_url=base_url, timeout=_TIMEOUT) - self._channel = channel self._max_characters = max_characters self._clock = clock @@ -51,7 +49,6 @@ async def stream_turn( turn, transaction_id=transaction_id, now=self._clock(), - channel=self._channel, max_characters=self._max_characters, ) # Leaving this block, including when the reader stops early, closes the diff --git a/src/experience_api/chat/adapters/dss/mapping.py b/src/experience_api/chat/adapters/dss/mapping.py index 1522a63..2dada9c 100644 --- a/src/experience_api/chat/adapters/dss/mapping.py +++ b/src/experience_api/chat/adapters/dss/mapping.py @@ -46,7 +46,6 @@ def to_dss_request( *, transaction_id: str, now: datetime, - channel: str, max_characters: int, ) -> dict[str, Any]: """The `/v1/turns` body for one turn, contract §7. @@ -70,7 +69,7 @@ def to_dss_request( for message in (*turn.history, Message("user", turn.query)) ], attributes=schemas.Attributes( - channel=channel, + channel=turn.channel, source_language=turn.source_language, target_language=turn.target_language, location=( diff --git a/src/experience_api/chat/adapters/http/mapping.py b/src/experience_api/chat/adapters/http/mapping.py index 74661d2..2054f17 100644 --- a/src/experience_api/chat/adapters/http/mapping.py +++ b/src/experience_api/chat/adapters/http/mapping.py @@ -8,6 +8,7 @@ from experience_api.chat.domain import ( Answer, Block, + Channel, ChatEvent, ChatTurn, Completed, @@ -34,6 +35,7 @@ def to_chat_turn(body: schemas.ChatRequest) -> ChatTurn: if location else None ), + channel=Channel.WEB, ) diff --git a/src/experience_api/chat/domain.py b/src/experience_api/chat/domain.py index 93a237e..a017dcf 100644 --- a/src/experience_api/chat/domain.py +++ b/src/experience_api/chat/domain.py @@ -3,11 +3,20 @@ from __future__ import annotations from dataclasses import dataclass +from enum import StrEnum from typing import Literal Role = Literal["user", "assistant"] +class Channel(StrEnum): + """Where a chat comes from. The members are the channels this API accepts, + a subset of the DSS's own set. Adding one is a code change, because the DSS + shapes each answer for its channel.""" + + WEB = "web" + + @dataclass(frozen=True) class Message: role: Role @@ -31,6 +40,7 @@ class ChatTurn: source_language: str target_language: str location: Location | None + channel: Channel # --------------------------------------------------------------------------- diff --git a/tests/integration/test_http_dss_client.py b/tests/integration/test_http_dss_client.py index ac7cb9c..8e34d67 100644 --- a/tests/integration/test_http_dss_client.py +++ b/tests/integration/test_http_dss_client.py @@ -23,7 +23,6 @@ def _client(server: HTTPServer) -> HttpDssClient: return HttpDssClient( base_url=server.url_for(""), - channel="web", max_characters=1200, clock=lambda: NOW, ) @@ -58,7 +57,7 @@ async def test_sends_the_contract_request(httpserver: HTTPServer) -> None: assert request.headers["Accept"] == "text/event-stream" assert request.headers["Content-Type"] == "application/json" assert json.loads(request.get_data()) == to_dss_request( - TURN, transaction_id="tx-1", now=NOW, channel="web", max_characters=1200 + TURN, transaction_id="tx-1", now=NOW, max_characters=1200 ) diff --git a/tests/unit/chat/test_dss_request.py b/tests/unit/chat/test_dss_request.py index 7094285..52fd153 100644 --- a/tests/unit/chat/test_dss_request.py +++ b/tests/unit/chat/test_dss_request.py @@ -6,6 +6,7 @@ from experience_api.chat.adapters.dss.mapping import to_dss_request from experience_api.chat.adapters.http.mapping import to_chat_turn from experience_api.chat.adapters.http.schemas import ChatRequest +from experience_api.chat.domain import Channel from tests.support.examples import DSS_REQUEST, FOLLOW_UP TURN = to_chat_turn(ChatRequest.model_validate(FOLLOW_UP)) @@ -17,7 +18,6 @@ def _request(turn=TURN) -> dict: # noqa: ANN001 turn, transaction_id="3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b", now=NOW, - channel="web", max_characters=1200, ) @@ -46,3 +46,9 @@ def test_a_first_message_is_the_only_input() -> None: assert inputs == [ {"role": "user", "content": [{"type": "text", "text": TURN.query}]} ] + + +def test_the_channel_comes_from_the_turn() -> None: + attributes = _request(replace(TURN, channel=Channel.WEB))["message"]["attributes"] + + assert attributes["channel"] == "web" diff --git a/tests/unit/chat/test_fake_dss.py b/tests/unit/chat/test_fake_dss.py index cdf0b25..4eb8f4d 100644 --- a/tests/unit/chat/test_fake_dss.py +++ b/tests/unit/chat/test_fake_dss.py @@ -2,6 +2,7 @@ from experience_api.chat.adapters.dss.fake import FakeDssClient from experience_api.chat.domain import ( + Channel, ChatTurn, Citation, DssDelta, @@ -20,6 +21,7 @@ source_language="en", target_language="en", location=None, + channel=Channel.WEB, ) diff --git a/tests/unit/chat/test_http_mapping.py b/tests/unit/chat/test_http_mapping.py index 12ac208..62fd12a 100644 --- a/tests/unit/chat/test_http_mapping.py +++ b/tests/unit/chat/test_http_mapping.py @@ -4,7 +4,7 @@ from experience_api.chat.adapters.http.mapping import to_chat_turn from experience_api.chat.adapters.http.schemas import ChatRequest -from experience_api.chat.domain import ChatTurn, Location, Message +from experience_api.chat.domain import Channel, ChatTurn, Location, Message from tests.support.examples import FOLLOW_UP as EXAMPLE @@ -22,6 +22,7 @@ def test_the_contract_example_becomes_a_turn() -> None: source_language="en", target_language="en", location=Location(latitude=20.0059, longitude=73.7898), + channel=Channel.WEB, ) From 960b5064fc32c332a5febbc2521831b156bbdd50 Mon Sep 17 00:00:00 2001 From: bhavesh-suvalaka Date: Wed, 21 Oct 2026 15:01:17 +0530 Subject: [PATCH 4/9] feat: let the client say which channel a chat comes from [#192] Co-Authored-By: Claude Opus 5.5 --- src/experience_api/app.py | 5 ++ .../chat/adapters/http/dependencies.py | 5 ++ .../chat/adapters/http/mapping.py | 4 +- .../chat/adapters/http/routes.py | 10 ++-- .../chat/adapters/http/schemas.py | 4 ++ src/experience_api/settings.py | 13 ++--- tests/api/test_app.py | 10 ++++ tests/api/test_chat_validation.py | 52 +++++++++++++++++++ tests/integration/test_http_dss_client.py | 6 +-- tests/support/examples.py | 17 ++++++ tests/unit/chat/test_dss_request.py | 6 +-- tests/unit/chat/test_fake_dss.py | 15 +----- tests/unit/chat/test_http_mapping.py | 46 ++++++++++------ tests/unit/chat/test_request_schema.py | 18 +++++++ tests/unit/chat/test_service.py | 6 +-- tests/unit/test_settings.py | 2 +- 16 files changed, 165 insertions(+), 54 deletions(-) diff --git a/src/experience_api/app.py b/src/experience_api/app.py index d4d54d9..d63ff2d 100644 --- a/src/experience_api/app.py +++ b/src/experience_api/app.py @@ -23,6 +23,7 @@ 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 from experience_api.chat.service import ChatService from experience_api.settings import Settings @@ -47,6 +48,9 @@ def create_app( new_id: Callable[[], str] = lambda: str(uuid4()), ) -> FastAPI: settings = settings if settings is not None else Settings() + # Checked here, not per request: a typo stops the app instead of failing + # the first turn that sends no channel. + default_channel = Channel(settings.channel) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: @@ -55,6 +59,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: async with AsyncExitStack() as stack: client = dss if dss is not None else await _dss_client(settings, stack) app.state.chat_service = ChatService(client, new_id=new_id) + app.state.default_channel = default_channel yield app = FastAPI(title="Experience API", lifespan=lifespan) diff --git a/src/experience_api/chat/adapters/http/dependencies.py b/src/experience_api/chat/adapters/http/dependencies.py index c7f70e8..b20805b 100644 --- a/src/experience_api/chat/adapters/http/dependencies.py +++ b/src/experience_api/chat/adapters/http/dependencies.py @@ -4,8 +4,13 @@ from fastapi import Request +from experience_api.chat.domain import Channel from experience_api.chat.service import ChatService def get_chat_service(request: Request) -> ChatService: return request.app.state.chat_service + + +def get_default_channel(request: Request) -> Channel: + return request.app.state.default_channel diff --git a/src/experience_api/chat/adapters/http/mapping.py b/src/experience_api/chat/adapters/http/mapping.py index 2054f17..1f41ae4 100644 --- a/src/experience_api/chat/adapters/http/mapping.py +++ b/src/experience_api/chat/adapters/http/mapping.py @@ -21,7 +21,7 @@ ) -def to_chat_turn(body: schemas.ChatRequest) -> ChatTurn: +def to_chat_turn(body: schemas.ChatRequest, *, default_channel: Channel) -> ChatTurn: location = body.location return ChatTurn( session_id=body.session_id, @@ -35,7 +35,7 @@ def to_chat_turn(body: schemas.ChatRequest) -> ChatTurn: if location else None ), - channel=Channel.WEB, + channel=body.channel if body.channel is not None else default_channel, ) diff --git a/src/experience_api/chat/adapters/http/routes.py b/src/experience_api/chat/adapters/http/routes.py index c2ae8bb..3db5c17 100644 --- a/src/experience_api/chat/adapters/http/routes.py +++ b/src/experience_api/chat/adapters/http/routes.py @@ -16,10 +16,13 @@ from fastapi.responses import JSONResponse, StreamingResponse from experience_api.chat.adapters.http import sse -from experience_api.chat.adapters.http.dependencies import get_chat_service +from experience_api.chat.adapters.http.dependencies import ( + get_chat_service, + get_default_channel, +) from experience_api.chat.adapters.http.mapping import to_chat_turn, to_final_answer from experience_api.chat.adapters.http.schemas import ChatRequest, FinalAnswer, wire -from experience_api.chat.domain import ChatEvent, Completed +from experience_api.chat.domain import Channel, ChatEvent, Completed from experience_api.chat.service import ChatService router = APIRouter() @@ -33,9 +36,10 @@ async def chat( body: ChatRequest, service: Annotated[ChatService, Depends(get_chat_service)], + default_channel: Annotated[Channel, Depends(get_default_channel)], accept: Annotated[str | None, Header()] = None, ) -> Response: - events = await service.open(to_chat_turn(body)) + events = await service.open(to_chat_turn(body, default_channel=default_channel)) if _wants_stream(accept): return StreamingResponse( sse.frames(events), media_type=sse.MEDIA_TYPE, headers=sse.HEADERS diff --git a/src/experience_api/chat/adapters/http/schemas.py b/src/experience_api/chat/adapters/http/schemas.py index e587dfa..2e605c8 100644 --- a/src/experience_api/chat/adapters/http/schemas.py +++ b/src/experience_api/chat/adapters/http/schemas.py @@ -22,6 +22,8 @@ ) from pydantic.alias_generators import to_camel +from experience_api.chat.domain import Channel + # BCP 47, the same pattern the DSS applies. BCP47 = r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$" @@ -76,6 +78,8 @@ class ChatRequest(_WireIn): history: list[HistoryItem] language: Language location: Location | None = None + # Missing or null: the deployment's default, filled in by the mapping. + channel: Channel | None = None # --------------------------------------------------------------------------- diff --git a/src/experience_api/settings.py b/src/experience_api/settings.py index a696074..037282e 100644 --- a/src/experience_api/settings.py +++ b/src/experience_api/settings.py @@ -1,8 +1,9 @@ """Configuration, from `EXPERIENCE_API_*` environment variables. -Read only by `app.py`, which hands services plain values. A bad mode, channel -or length stops the app from starting. The DSS URL is not checked here: a wrong -one fails like an unreachable DSS, on the first turn. +Read only by `app.py`, which hands services plain values. A bad mode or length +stops the app from starting here; a bad channel stops it in `app.py`, which +owns the channel enum. The DSS URL is not checked: a wrong one fails like an +unreachable DSS, on the first turn. """ from __future__ import annotations @@ -19,7 +20,7 @@ class Settings(BaseSettings): # `fake` answers every turn from memory; `http` calls the DSS. dss_mode: Literal["fake", "http"] = "fake" dss_base_url: str = "http://localhost:8077" - # Sent to the DSS as `attributes.channel` and `response.maxCharacters` - # (contract §7). The channels are the DSS's own set. - channel: Literal["web", "whatsapp", "voice", "sms"] = "web" + # The channel for a turn whose client sends none (contract §4, §7). A plain + # string: `app.py` converts it to the chat `Channel` enum at startup. + channel: str = "web" max_characters: int = Field(default=1200, ge=1) diff --git a/tests/api/test_app.py b/tests/api/test_app.py index bcb597b..a4962e6 100644 --- a/tests/api/test_app.py +++ b/tests/api/test_app.py @@ -2,6 +2,10 @@ is how `uvicorn --factory` calls it.""" import httpx +import pytest + +from experience_api.app import create_app +from experience_api.settings import Settings async def test_app_boots_and_describes_itself(running: httpx.AsyncClient) -> None: @@ -9,3 +13,9 @@ async def test_app_boots_and_describes_itself(running: httpx.AsyncClient) -> Non assert response.status_code == 200 assert response.json()["info"]["title"] == "Experience API" + + +@pytest.mark.parametrize("channel", ["voice", "email", "WEB"]) +def test_a_default_channel_outside_the_enum_stops_the_app(channel: str) -> None: + with pytest.raises(ValueError, match=channel): + create_app(Settings(channel=channel)) diff --git a/tests/api/test_chat_validation.py b/tests/api/test_chat_validation.py index 8224e28..b5c89c8 100644 --- a/tests/api/test_chat_validation.py +++ b/tests/api/test_chat_validation.py @@ -1,11 +1,44 @@ """Rejected bodies get FastAPI's default 422 (contract §6.1, amended). Whatever the body held, the rejection itself must be sendable.""" +from collections.abc import AsyncGenerator + import httpx +import pytest +from fastapi import FastAPI +from experience_api.app import create_app +from experience_api.chat.adapters.dss.fake import FakeDssClient +from experience_api.chat.domain import ChatTurn, DssEvent from tests.support.examples import FOLLOW_UP +class _SpyDss(FakeDssClient): + """The fake DSS, counting the turns it gets. A rejected body must never + reach it.""" + + def __init__(self) -> None: + super().__init__() + self.turns: list[ChatTurn] = [] + + async def stream_turn( + self, turn: ChatTurn, *, transaction_id: str + ) -> AsyncGenerator[DssEvent]: + self.turns.append(turn) + async for event in super().stream_turn(turn, transaction_id=transaction_id): + yield event + + +@pytest.fixture +def spy() -> _SpyDss: + return _SpyDss() + + +@pytest.fixture +def app(spy: _SpyDss) -> FastAPI: + return create_app(dss=spy) + + async def test_a_non_finite_coordinate_is_a_422_not_a_500( running: httpx.AsyncClient, ) -> None: @@ -22,3 +55,22 @@ async def test_a_non_finite_coordinate_is_a_422_not_a_500( assert response.status_code == 422, bad assert response.json()["detail"][0]["loc"] == ["body", "location", "longitude"] + + +async def test_an_unknown_channel_is_a_422_and_never_reaches_the_dss( + running: httpx.AsyncClient, spy: _SpyDss +) -> None: + response = await running.post("/v1/chat", json=FOLLOW_UP | {"channel": "voice"}) + + assert response.status_code == 422 + assert response.json()["detail"][0]["loc"] == ["body", "channel"] + assert spy.turns == [] + + +async def test_a_web_channel_reaches_the_dss( + running: httpx.AsyncClient, spy: _SpyDss +) -> None: + response = await running.post("/v1/chat", json=FOLLOW_UP | {"channel": "web"}) + + assert response.status_code == 200 + assert [turn.channel for turn in spy.turns] == ["web"] diff --git a/tests/integration/test_http_dss_client.py b/tests/integration/test_http_dss_client.py index 8e34d67..01606de 100644 --- a/tests/integration/test_http_dss_client.py +++ b/tests/integration/test_http_dss_client.py @@ -10,13 +10,11 @@ from experience_api.chat.adapters.dss.client import HttpDssClient from experience_api.chat.adapters.dss.mapping import to_dss_request -from experience_api.chat.adapters.http.mapping import to_chat_turn -from experience_api.chat.adapters.http.schemas import ChatRequest from experience_api.chat.domain import DssDelta, DssFinished, DssStarted from tests.support import dss_streams -from tests.support.examples import FOLLOW_UP +from tests.support.examples import FOLLOW_UP_TURN -TURN = to_chat_turn(ChatRequest.model_validate(FOLLOW_UP)) +TURN = FOLLOW_UP_TURN NOW = datetime(2026, 9, 23, 8, 0, tzinfo=UTC) diff --git a/tests/support/examples.py b/tests/support/examples.py index cf1d5f7..f47e81c 100644 --- a/tests/support/examples.py +++ b/tests/support/examples.py @@ -3,6 +3,8 @@ from typing import Any +from experience_api.chat.domain import Channel, ChatTurn, Location, Message + # Contract §4, the follow-up request. FOLLOW_UP: dict[str, Any] = { "sessionId": "68a3872f-3f0d-4cf6-99a3-a350132a0080", @@ -16,6 +18,21 @@ "location": {"latitude": 20.0059, "longitude": 73.7898}, } +# The same request as a turn, with no channel sent: the default, `web`. +FOLLOW_UP_TURN = ChatTurn( + session_id="68a3872f-3f0d-4cf6-99a3-a350132a0080", + message_id="1ab38d6c-6fdb-4849-8ea1-da5e80a8687c", + query="And what about tomorrow?", + history=( + Message("user", "What is the weather today at my location?"), + Message("assistant", "Nashik is clear today, 31°C, no rain expected."), + ), + source_language="en", + target_language="en", + location=Location(latitude=20.0059, longitude=73.7898), + channel=Channel.WEB, +) + # Contract §5.2, the answered FinalAnswer. ANSWERED: dict[str, Any] = { "sessionId": "68a3872f-3f0d-4cf6-99a3-a350132a0080", diff --git a/tests/unit/chat/test_dss_request.py b/tests/unit/chat/test_dss_request.py index 52fd153..3a6ef9d 100644 --- a/tests/unit/chat/test_dss_request.py +++ b/tests/unit/chat/test_dss_request.py @@ -4,12 +4,10 @@ from datetime import UTC, datetime from experience_api.chat.adapters.dss.mapping import to_dss_request -from experience_api.chat.adapters.http.mapping import to_chat_turn -from experience_api.chat.adapters.http.schemas import ChatRequest from experience_api.chat.domain import Channel -from tests.support.examples import DSS_REQUEST, FOLLOW_UP +from tests.support.examples import DSS_REQUEST, FOLLOW_UP_TURN -TURN = to_chat_turn(ChatRequest.model_validate(FOLLOW_UP)) +TURN = FOLLOW_UP_TURN NOW = datetime(2026, 9, 23, 8, 0, tzinfo=UTC) diff --git a/tests/unit/chat/test_fake_dss.py b/tests/unit/chat/test_fake_dss.py index 4eb8f4d..0b3c6d3 100644 --- a/tests/unit/chat/test_fake_dss.py +++ b/tests/unit/chat/test_fake_dss.py @@ -2,8 +2,6 @@ from experience_api.chat.adapters.dss.fake import FakeDssClient from experience_api.chat.domain import ( - Channel, - ChatTurn, Citation, DssDelta, DssFinished, @@ -11,18 +9,9 @@ TextBlock, ) from experience_api.chat.ports import DssClient -from tests.support.examples import FAKE_PIECES, FAKE_TEXT +from tests.support.examples import FAKE_PIECES, FAKE_TEXT, FOLLOW_UP_TURN -TURN = ChatTurn( - session_id="s", - message_id="m", - query="And what about tomorrow?", - history=(), - source_language="en", - target_language="en", - location=None, - channel=Channel.WEB, -) +TURN = FOLLOW_UP_TURN async def test_starts_streams_the_pieces_then_finishes() -> None: diff --git a/tests/unit/chat/test_http_mapping.py b/tests/unit/chat/test_http_mapping.py index 62fd12a..67904b4 100644 --- a/tests/unit/chat/test_http_mapping.py +++ b/tests/unit/chat/test_http_mapping.py @@ -4,32 +4,28 @@ from experience_api.chat.adapters.http.mapping import to_chat_turn from experience_api.chat.adapters.http.schemas import ChatRequest -from experience_api.chat.domain import Channel, ChatTurn, Location, Message +from experience_api.chat.domain import Channel from tests.support.examples import FOLLOW_UP as EXAMPLE +from tests.support.examples import FOLLOW_UP_TURN def test_the_contract_example_becomes_a_turn() -> None: - turn = to_chat_turn(ChatRequest.model_validate(EXAMPLE)) - - assert turn == ChatTurn( - session_id="68a3872f-3f0d-4cf6-99a3-a350132a0080", - message_id="1ab38d6c-6fdb-4849-8ea1-da5e80a8687c", - query="And what about tomorrow?", - history=( - Message("user", "What is the weather today at my location?"), - Message("assistant", "Nashik is clear today, 31°C, no rain expected."), - ), - source_language="en", - target_language="en", - location=Location(latitude=20.0059, longitude=73.7898), - channel=Channel.WEB, + turn = to_chat_turn( + ChatRequest.model_validate(EXAMPLE), default_channel=Channel.WEB ) + assert turn == FOLLOW_UP_TURN + def test_no_location_stays_none() -> None: body: dict[str, Any] = {k: v for k, v in EXAMPLE.items() if k != "location"} - assert to_chat_turn(ChatRequest.model_validate(body)).location is None + assert ( + to_chat_turn( + ChatRequest.model_validate(body), default_channel=Channel.WEB + ).location + is None + ) def test_ids_pass_through_exactly_as_sent() -> None: @@ -38,7 +34,23 @@ def test_ids_pass_through_exactly_as_sent() -> None: "messageId": "1AB38D6C-6fdb-4849-8ea1-da5e80a8687c", } - turn = to_chat_turn(ChatRequest.model_validate(body)) + turn = to_chat_turn(ChatRequest.model_validate(body), default_channel=Channel.WEB) assert turn.session_id == "68A3872F-3F0D-4CF6-99A3-A350132A0080" assert turn.message_id == "1AB38D6C-6fdb-4849-8ea1-da5e80a8687c" + + +def test_a_sent_channel_is_used() -> None: + body = EXAMPLE | {"channel": "web"} + + turn = to_chat_turn(ChatRequest.model_validate(body), default_channel=Channel.WEB) + + assert turn.channel is Channel.WEB + + +def test_no_channel_takes_the_default() -> None: + turn = to_chat_turn( + ChatRequest.model_validate(EXAMPLE), default_channel=Channel.WEB + ) + + assert turn.channel is Channel.WEB diff --git a/tests/unit/chat/test_request_schema.py b/tests/unit/chat/test_request_schema.py index 08ebebf..b03e509 100644 --- a/tests/unit/chat/test_request_schema.py +++ b/tests/unit/chat/test_request_schema.py @@ -126,3 +126,21 @@ def test_allowed_variations(body: dict[str, Any]) -> None: def test_rejected(body: dict[str, Any]) -> None: with pytest.raises(ValidationError): ChatRequest.model_validate(body) + + +@pytest.mark.parametrize("value", ["voice", "WEB", ""]) +def test_a_channel_outside_the_enum_is_rejected(value: str) -> None: + with pytest.raises(ValidationError): + ChatRequest.model_validate(_with("channel", value)) + + +def test_a_missing_channel_is_none() -> None: + assert ChatRequest.model_validate(EXAMPLE).channel is None + + +def test_a_null_channel_is_none() -> None: + assert ChatRequest.model_validate(_with("channel", None)).channel is None + + +def test_web_is_a_channel() -> None: + assert ChatRequest.model_validate(_with("channel", "web")).channel == "web" diff --git a/tests/unit/chat/test_service.py b/tests/unit/chat/test_service.py index 41d8f66..40db292 100644 --- a/tests/unit/chat/test_service.py +++ b/tests/unit/chat/test_service.py @@ -1,8 +1,6 @@ """`ChatService` opens a DSS turn and relays it as the client's events.""" from experience_api.chat.adapters.dss.fake import FakeDssClient -from experience_api.chat.adapters.http.mapping import to_chat_turn -from experience_api.chat.adapters.http.schemas import ChatRequest from experience_api.chat.domain import ( Completed, Delta, @@ -12,9 +10,9 @@ TurnIds, ) from experience_api.chat.service import ChatService -from tests.support.examples import FAKE_PIECES, FOLLOW_UP +from tests.support.examples import FAKE_PIECES, FOLLOW_UP_TURN -TURN = to_chat_turn(ChatRequest.model_validate(FOLLOW_UP)) +TURN = FOLLOW_UP_TURN def _service() -> ChatService: diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index cc48a67..8a9f99c 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -34,7 +34,7 @@ def test_read_from_the_prefixed_environment(monkeypatch: pytest.MonkeyPatch) -> @pytest.mark.parametrize( ("name", "value"), - [("DSS_MODE", "real"), ("MAX_CHARACTERS", "0"), ("CHANNEL", "email")], + [("DSS_MODE", "real"), ("MAX_CHARACTERS", "0")], ) def test_a_bad_value_stops_the_app_from_starting( monkeypatch: pytest.MonkeyPatch, name: str, value: str From e74ae66200257b87ee0fbec34646af5ece627b3a Mon Sep 17 00:00:00 2001 From: bhavesh-suvalaka Date: Wed, 21 Oct 2026 15:17:18 +0530 Subject: [PATCH 5/9] feat: send the deployment's sender id to the DSS [#192] Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 + docker-compose.yml | 2 + src/experience_api/app.py | 11 ++-- .../chat/adapters/dss/client.py | 3 ++ .../chat/adapters/dss/mapping.py | 2 + .../chat/adapters/dss/schemas.py | 1 + src/experience_api/settings.py | 11 +++- tests/api/test_app.py | 15 ++++-- tests/conftest.py | 12 +++++ tests/integration/test_app_with_http_dss.py | 10 +++- tests/integration/test_http_dss_client.py | 5 +- tests/support/examples.py | 4 ++ tests/unit/chat/test_dss_request.py | 3 +- tests/unit/test_settings.py | 51 +++++++++++++++++-- vulture_whitelist.py | 3 ++ 15 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6d604e8 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# The network participant this deployment serves. Required. +EXPERIENCE_API_SENDER_ID=experience-api.local \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index b8a3b67..19f16bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: context: . dockerfile: Dockerfile 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} diff --git a/src/experience_api/app.py b/src/experience_api/app.py index d63ff2d..69cc1e2 100644 --- a/src/experience_api/app.py +++ b/src/experience_api/app.py @@ -4,9 +4,9 @@ needs for its whole life are built in `lifespan` and kept on `app.state`. It is also the only reader of settings: services get plain values. -`uvicorn --factory` calls it with no arguments, so it must boot with nothing -configured: it reads `Settings` from the environment. Tests pass their own -settings, DSS and id factory. +`uvicorn --factory` calls it with no arguments, so it must boot with only the +sender id configured: it reads `Settings` from the environment and `.env`. +Tests pass their own settings, DSS and id factory. `/healthz` lives here because it belongs to the running service, not to any feature. @@ -47,7 +47,9 @@ def create_app( dss: DssClient | None = None, new_id: Callable[[], str] = lambda: str(uuid4()), ) -> FastAPI: - settings = settings if settings is not None else Settings() + # pyright reads `sender_id` as a required argument; the environment or + # `.env` supplies it, and a missing one fails validation here. + settings = settings if settings is not None else Settings() # pyright: ignore[reportCallIssue] # Checked here, not per request: a typo stops the app instead of failing # the first turn that sends no channel. default_channel = Channel(settings.channel) @@ -75,6 +77,7 @@ async def _dss_client(settings: Settings, stack: AsyncExitStack) -> DssClient: return await stack.enter_async_context( HttpDssClient( base_url=settings.dss_base_url, + sender_id=settings.sender_id, max_characters=settings.max_characters, ) ) diff --git a/src/experience_api/chat/adapters/dss/client.py b/src/experience_api/chat/adapters/dss/client.py index c3cd3ab..f35fa0c 100644 --- a/src/experience_api/chat/adapters/dss/client.py +++ b/src/experience_api/chat/adapters/dss/client.py @@ -29,10 +29,12 @@ def __init__( self, *, base_url: str, + sender_id: str, max_characters: int, clock: Callable[[], datetime] = lambda: datetime.now(UTC), ) -> None: self._http = httpx.AsyncClient(base_url=base_url, timeout=_TIMEOUT) + self._sender_id = sender_id self._max_characters = max_characters self._clock = clock @@ -49,6 +51,7 @@ async def stream_turn( turn, transaction_id=transaction_id, now=self._clock(), + sender_id=self._sender_id, max_characters=self._max_characters, ) # Leaving this block, including when the reader stops early, closes the diff --git a/src/experience_api/chat/adapters/dss/mapping.py b/src/experience_api/chat/adapters/dss/mapping.py index 2dada9c..e9764c0 100644 --- a/src/experience_api/chat/adapters/dss/mapping.py +++ b/src/experience_api/chat/adapters/dss/mapping.py @@ -46,6 +46,7 @@ def to_dss_request( *, transaction_id: str, now: datetime, + sender_id: str, max_characters: int, ) -> dict[str, Any]: """The `/v1/turns` body for one turn, contract §7. @@ -62,6 +63,7 @@ def to_dss_request( session_id=turn.session_id, transaction_id=transaction_id, message_id=turn.message_id, + sender_id=sender_id, ), message=schemas.Message( input=[ diff --git a/src/experience_api/chat/adapters/dss/schemas.py b/src/experience_api/chat/adapters/dss/schemas.py index 7f2a0fa..47ab0dc 100644 --- a/src/experience_api/chat/adapters/dss/schemas.py +++ b/src/experience_api/chat/adapters/dss/schemas.py @@ -34,6 +34,7 @@ class Context(_Request): session_id: str transaction_id: str message_id: str + sender_id: str class TextPart(_Request): diff --git a/src/experience_api/settings.py b/src/experience_api/settings.py index 037282e..1303dd1 100644 --- a/src/experience_api/settings.py +++ b/src/experience_api/settings.py @@ -1,4 +1,7 @@ -"""Configuration, from `EXPERIENCE_API_*` environment variables. +"""Configuration, from `EXPERIENCE_API_*` environment variables, then `.env`. + +`.env` is for local runs; copy `.env.example`. A real environment variable +always wins over it, and the image never carries one (`.dockerignore`). Read only by `app.py`, which hands services plain values. A bad mode or length stops the app from starting here; a bad channel stops it in `app.py`, which @@ -15,8 +18,12 @@ class Settings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="EXPERIENCE_API_") + model_config = SettingsConfigDict(env_prefix="EXPERIENCE_API_", env_file=".env") + # The network participant this deployment serves, sent to the DSS as + # `context.senderId` (contract §7). No default: a deployment that forgets + # it must not start as someone else. The network registry owns its format. + sender_id: str = Field(pattern=r"\S") # `fake` answers every turn from memory; `http` calls the DSS. dss_mode: Literal["fake", "http"] = "fake" dss_base_url: str = "http://localhost:8077" diff --git a/tests/api/test_app.py b/tests/api/test_app.py index a4962e6..ac328d1 100644 --- a/tests/api/test_app.py +++ b/tests/api/test_app.py @@ -1,11 +1,13 @@ -"""`create_app()` must boot with no arguments and no environment, because that -is how `uvicorn --factory` calls it.""" +"""`create_app()` must boot with no arguments and only the sender id set, +because that is how `uvicorn --factory` calls it.""" import httpx import pytest +from pydantic import ValidationError from experience_api.app import create_app from experience_api.settings import Settings +from tests.support.examples import SENDER_ID async def test_app_boots_and_describes_itself(running: httpx.AsyncClient) -> None: @@ -18,4 +20,11 @@ async def test_app_boots_and_describes_itself(running: httpx.AsyncClient) -> Non @pytest.mark.parametrize("channel", ["voice", "email", "WEB"]) def test_a_default_channel_outside_the_enum_stops_the_app(channel: str) -> None: with pytest.raises(ValueError, match=channel): - create_app(Settings(channel=channel)) + create_app(Settings(sender_id=SENDER_ID, channel=channel)) + + +def test_no_sender_id_stops_the_app(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("EXPERIENCE_API_SENDER_ID") + + with pytest.raises(ValidationError, match="sender_id"): + create_app() diff --git a/tests/conftest.py b/tests/conftest.py index 5cb89a6..57b8128 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,18 @@ from fastapi import FastAPI from experience_api.app import create_app +from experience_api.settings import Settings +from tests.support.examples import SENDER_ID + + +@pytest.fixture(autouse=True) +def _settings_from_the_test_only(monkeypatch: pytest.MonkeyPatch) -> None: + """Tests never read a developer's `.env`: it could point them at a real DSS. + Every test runs as the contract's example sender. A test that needs `.env` + points `env_file` at its own file the same way.""" + + monkeypatch.setitem(Settings.model_config, "env_file", None) + monkeypatch.setenv("EXPERIENCE_API_SENDER_ID", SENDER_ID) @pytest.fixture diff --git a/tests/integration/test_app_with_http_dss.py b/tests/integration/test_app_with_http_dss.py index 987c481..f5d3499 100644 --- a/tests/integration/test_app_with_http_dss.py +++ b/tests/integration/test_app_with_http_dss.py @@ -9,7 +9,7 @@ from experience_api.app import create_app from experience_api.settings import Settings from tests.support import dss_streams -from tests.support.examples import FOLLOW_UP +from tests.support.examples import FOLLOW_UP, SENDER_ID @pytest.fixture @@ -17,7 +17,13 @@ def app(httpserver: HTTPServer) -> FastAPI: httpserver.expect_request("/v1/turns", method="POST").respond_with_data( dss_streams.ANSWERED, content_type="text/event-stream" ) - return create_app(Settings(dss_mode="http", dss_base_url=httpserver.url_for(""))) + return create_app( + Settings( + sender_id=SENDER_ID, + dss_mode="http", + dss_base_url=httpserver.url_for(""), + ) + ) async def test_a_streamed_turn(running: httpx.AsyncClient) -> None: diff --git a/tests/integration/test_http_dss_client.py b/tests/integration/test_http_dss_client.py index 01606de..4595ac8 100644 --- a/tests/integration/test_http_dss_client.py +++ b/tests/integration/test_http_dss_client.py @@ -12,7 +12,7 @@ from experience_api.chat.adapters.dss.mapping import to_dss_request from experience_api.chat.domain import DssDelta, DssFinished, DssStarted from tests.support import dss_streams -from tests.support.examples import FOLLOW_UP_TURN +from tests.support.examples import FOLLOW_UP_TURN, SENDER_ID TURN = FOLLOW_UP_TURN NOW = datetime(2026, 9, 23, 8, 0, tzinfo=UTC) @@ -21,6 +21,7 @@ def _client(server: HTTPServer) -> HttpDssClient: return HttpDssClient( base_url=server.url_for(""), + sender_id=SENDER_ID, max_characters=1200, clock=lambda: NOW, ) @@ -55,7 +56,7 @@ async def test_sends_the_contract_request(httpserver: HTTPServer) -> None: assert request.headers["Accept"] == "text/event-stream" assert request.headers["Content-Type"] == "application/json" assert json.loads(request.get_data()) == to_dss_request( - TURN, transaction_id="tx-1", now=NOW, max_characters=1200 + TURN, transaction_id="tx-1", now=NOW, sender_id=SENDER_ID, max_characters=1200 ) diff --git a/tests/support/examples.py b/tests/support/examples.py index f47e81c..8588fd9 100644 --- a/tests/support/examples.py +++ b/tests/support/examples.py @@ -18,6 +18,9 @@ "location": {"latitude": 20.0059, "longitude": 73.7898}, } +# Contract §7: the network participant a deployment serves. +SENDER_ID = "exp-karnataka.da.gov.in" + # The same request as a turn, with no channel sent: the default, `web`. FOLLOW_UP_TURN = ChatTurn( session_id="68a3872f-3f0d-4cf6-99a3-a350132a0080", @@ -87,6 +90,7 @@ "sessionId": "68a3872f-3f0d-4cf6-99a3-a350132a0080", "transactionId": "3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b", "messageId": "1ab38d6c-6fdb-4849-8ea1-da5e80a8687c", + "senderId": SENDER_ID, }, "message": { "input": [ diff --git a/tests/unit/chat/test_dss_request.py b/tests/unit/chat/test_dss_request.py index 3a6ef9d..8525bd6 100644 --- a/tests/unit/chat/test_dss_request.py +++ b/tests/unit/chat/test_dss_request.py @@ -5,7 +5,7 @@ from experience_api.chat.adapters.dss.mapping import to_dss_request from experience_api.chat.domain import Channel -from tests.support.examples import DSS_REQUEST, FOLLOW_UP_TURN +from tests.support.examples import DSS_REQUEST, FOLLOW_UP_TURN, SENDER_ID TURN = FOLLOW_UP_TURN NOW = datetime(2026, 9, 23, 8, 0, tzinfo=UTC) @@ -16,6 +16,7 @@ def _request(turn=TURN) -> dict: # noqa: ANN001 turn, transaction_id="3c67dc05-6ba2-4ab4-bb7c-377e16a5ab5b", now=NOW, + sender_id=SENDER_ID, max_characters=1200, ) diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 8a9f99c..570901a 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -1,4 +1,6 @@ -"""Settings come from `EXPERIENCE_API_*` environment variables.""" +"""Settings come from `EXPERIENCE_API_*` environment variables, then `.env`.""" + +from pathlib import Path import pytest from pydantic import ValidationError @@ -6,11 +8,17 @@ from experience_api.settings import Settings +def _from_env() -> Settings: + """`Settings` as `app.py` builds it: from the environment and `.env` only.""" + + return Settings() # pyright: ignore[reportCallIssue] + + def test_defaults_run_against_the_fake_dss(monkeypatch: pytest.MonkeyPatch) -> None: for name in ("DSS_MODE", "DSS_BASE_URL", "CHANNEL", "MAX_CHARACTERS"): monkeypatch.delenv(f"EXPERIENCE_API_{name}", raising=False) - settings = Settings() + settings = _from_env() assert settings.dss_mode == "fake" assert settings.dss_base_url == "http://localhost:8077" @@ -23,7 +31,7 @@ def test_read_from_the_prefixed_environment(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setenv("EXPERIENCE_API_DSS_BASE_URL", "http://dss:8077") monkeypatch.setenv("EXPERIENCE_API_MAX_CHARACTERS", "600") - settings = Settings() + settings = _from_env() assert (settings.dss_mode, settings.dss_base_url, settings.max_characters) == ( "http", @@ -42,4 +50,39 @@ def test_a_bad_value_stops_the_app_from_starting( monkeypatch.setenv(f"EXPERIENCE_API_{name}", value) with pytest.raises(ValidationError): - Settings() + _from_env() + + +@pytest.mark.parametrize("value", [None, "", " "]) +def test_a_missing_or_blank_sender_id_stops_the_app( + monkeypatch: pytest.MonkeyPatch, value: str | None +) -> None: + if value is None: + monkeypatch.delenv("EXPERIENCE_API_SENDER_ID") + else: + monkeypatch.setenv("EXPERIENCE_API_SENDER_ID", value) + + with pytest.raises(ValidationError, match="sender_id"): + _from_env() + + +def test_the_sender_id_comes_from_dot_env( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv("EXPERIENCE_API_SENDER_ID") + env_file = tmp_path / ".env" + env_file.write_text("EXPERIENCE_API_SENDER_ID=experience-api.local\n") + monkeypatch.setitem(Settings.model_config, "env_file", env_file) + + assert _from_env().sender_id == "experience-api.local" + + +def test_the_environment_wins_over_dot_env( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("EXPERIENCE_API_SENDER_ID", "exp-from-env") + env_file = tmp_path / ".env" + env_file.write_text("EXPERIENCE_API_SENDER_ID=experience-api.local\n") + monkeypatch.setitem(Settings.model_config, "env_file", env_file) + + assert _from_env().sender_id == "exp-from-env" diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 47addcf..e780f93 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -17,3 +17,6 @@ _.user_id _.input _.user_context + +# An autouse pytest fixture: pytest calls it, our code never does. +_._settings_from_the_test_only From 5e33beb62682f23dfbd7a26055539d61a814153d Mon Sep 17 00:00:00 2001 From: bhavesh-suvalaka Date: Wed, 21 Oct 2026 15:45:00 +0530 Subject: [PATCH 6/9] fix: let .env hold keys that only Compose reads [#192] Co-Authored-By: Claude Opus 5.5 --- src/experience_api/settings.py | 7 ++++++- tests/unit/test_settings.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/experience_api/settings.py b/src/experience_api/settings.py index 1303dd1..34db5bc 100644 --- a/src/experience_api/settings.py +++ b/src/experience_api/settings.py @@ -18,7 +18,12 @@ class Settings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="EXPERIENCE_API_", env_file=".env") + # `extra="ignore"`: `.env` is shared with Compose, which reads keys of its + # own (`EXPERIENCE_API_HOST_PORT`). Unknown environment variables were + # always ignored; this makes `.env` behave the same. + model_config = SettingsConfigDict( + env_prefix="EXPERIENCE_API_", env_file=".env", extra="ignore" + ) # The network participant this deployment serves, sent to the DSS as # `context.senderId` (contract §7). No default: a deployment that forgets diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 570901a..cc8c314 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -86,3 +86,14 @@ def test_the_environment_wins_over_dot_env( monkeypatch.setitem(Settings.model_config, "env_file", env_file) assert _from_env().sender_id == "exp-from-env" + + +def test_a_dot_env_key_that_is_not_a_setting_is_ignored( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + # Compose reads `EXPERIENCE_API_HOST_PORT` from the same `.env`. + env_file = tmp_path / ".env" + env_file.write_text("EXPERIENCE_API_HOST_PORT=9000\n") + monkeypatch.setitem(Settings.model_config, "env_file", env_file) + + assert _from_env().sender_id From 60aa18d0aabbf3114e19ce1721be186e49af3806 Mon Sep 17 00:00:00 2001 From: bhavesh-suvalaka Date: Wed, 21 Oct 2026 16:02:55 +0530 Subject: [PATCH 7/9] fix: trim the sender id, and check it in every DSS mode [#192] Co-Authored-By: Claude Opus 5.5 --- src/experience_api/settings.py | 7 ++++--- tests/unit/test_settings.py | 23 ++++++++++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/experience_api/settings.py b/src/experience_api/settings.py index 34db5bc..a04109b 100644 --- a/src/experience_api/settings.py +++ b/src/experience_api/settings.py @@ -11,9 +11,9 @@ from __future__ import annotations -from typing import Literal +from typing import Annotated, Literal -from pydantic import Field +from pydantic import Field, StringConstraints from pydantic_settings import BaseSettings, SettingsConfigDict @@ -28,7 +28,8 @@ class Settings(BaseSettings): # The network participant this deployment serves, sent to the DSS as # `context.senderId` (contract §7). No default: a deployment that forgets # it must not start as someone else. The network registry owns its format. - sender_id: str = Field(pattern=r"\S") + # Trimmed, since a stray space is a copy-paste slip; blank after that fails. + sender_id: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] # `fake` answers every turn from memory; `http` calls the DSS. dss_mode: Literal["fake", "http"] = "fake" dss_base_url: str = "http://localhost:8077" diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index cc8c314..2fae4c8 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -53,10 +53,14 @@ def test_a_bad_value_stops_the_app_from_starting( _from_env() +@pytest.mark.parametrize("mode", ["fake", "http"]) @pytest.mark.parametrize("value", [None, "", " "]) def test_a_missing_or_blank_sender_id_stops_the_app( - monkeypatch: pytest.MonkeyPatch, value: str | None + monkeypatch: pytest.MonkeyPatch, mode: str, value: str | None ) -> None: + # In every mode: the fake DSS ignores the sender id, but a deployment that + # forgets it must still not start. + monkeypatch.setenv("EXPERIENCE_API_DSS_MODE", mode) if value is None: monkeypatch.delenv("EXPERIENCE_API_SENDER_ID") else: @@ -66,6 +70,23 @@ def test_a_missing_or_blank_sender_id_stops_the_app( _from_env() +@pytest.mark.parametrize( + "value", + [ + " exp-karnataka.da.gov.in", + "exp-karnataka.da.gov.in ", + "\texp-karnataka.da.gov.in\n", + ], +) +def test_a_padded_sender_id_is_trimmed( + monkeypatch: pytest.MonkeyPatch, value: str +) -> None: + # A stray space is a copy-paste slip; the DSS must get the clean id. + monkeypatch.setenv("EXPERIENCE_API_SENDER_ID", value) + + assert _from_env().sender_id == "exp-karnataka.da.gov.in" + + def test_the_sender_id_comes_from_dot_env( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: From ef70f87d0a762ba24f626081f891d5feb041df53 Mon Sep 17 00:00:00 2001 From: bhavesh-suvalaka Date: Wed, 21 Oct 2026 16:02:55 +0530 Subject: [PATCH 8/9] docs: document the sender id and the channel default in the README [#192] Co-Authored-By: Claude Opus 5.5 --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 67f71c3..74d641c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Needs [uv](https://docs.astral.sh/uv/). It installs Python 3.13 itself. ```bash uv sync +cp .env.example .env # once: a local sender id, which the API needs to start uv run uvicorn --factory experience_api.app:create_app --port 8078 ``` @@ -30,12 +31,16 @@ EXPERIENCE_API_DSS_MODE=http uv run uvicorn --factory experience_api.app:create_ | 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` | Sent to the DSS as the channel | +| `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 | -A bad mode, channel or length stops the API from starting; a wrong DSS URL +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 +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. @@ -52,7 +57,7 @@ docker compose up --build # add -d to run it in the background docker compose down ``` -Same port, same fake DSS. `EXPERIENCE_API_DSS_MODE=http docker compose up +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 reports healthy once `/healthz` answers. From 5350074ad51e6ea89b8f62b93e2ad2977f97979d Mon Sep 17 00:00:00 2001 From: bhavesh-suvalaka Date: Thu, 22 Oct 2026 10:36:12 +0530 Subject: [PATCH 9/9] docs: trim settings comments and clarify the null channel note [#192] Drop the extra="ignore" and sender id comments; the docs and git history hold that detail. Reword the channel comment: an absent key and an explicit null both mean the deployment default. Co-Authored-By: Claude Fable 5.1 --- src/experience_api/chat/adapters/http/schemas.py | 3 ++- src/experience_api/settings.py | 7 ------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/experience_api/chat/adapters/http/schemas.py b/src/experience_api/chat/adapters/http/schemas.py index 2e605c8..e1e1f28 100644 --- a/src/experience_api/chat/adapters/http/schemas.py +++ b/src/experience_api/chat/adapters/http/schemas.py @@ -78,7 +78,8 @@ class ChatRequest(_WireIn): history: list[HistoryItem] language: Language location: Location | None = None - # Missing or null: the deployment's default, filled in by the mapping. + # Both an absent key and an explicit `null` mean the deployment's default + # (contract §4). The mapping fills it in. channel: Channel | None = None diff --git a/src/experience_api/settings.py b/src/experience_api/settings.py index a04109b..0fb3635 100644 --- a/src/experience_api/settings.py +++ b/src/experience_api/settings.py @@ -18,17 +18,10 @@ class Settings(BaseSettings): - # `extra="ignore"`: `.env` is shared with Compose, which reads keys of its - # own (`EXPERIENCE_API_HOST_PORT`). Unknown environment variables were - # always ignored; this makes `.env` behave the same. model_config = SettingsConfigDict( env_prefix="EXPERIENCE_API_", env_file=".env", extra="ignore" ) - # The network participant this deployment serves, sent to the DSS as - # `context.senderId` (contract §7). No default: a deployment that forgets - # it must not start as someone else. The network registry owns its format. - # Trimmed, since a stray space is a copy-paste slip; blank after that fails. sender_id: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] # `fake` answers every turn from memory; `http` calls the DSS. dss_mode: Literal["fake", "http"] = "fake"