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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# The network participant this deployment serves. Required.
EXPERIENCE_API_SENDER_ID=experience-api.local
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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.

Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
121 changes: 121 additions & 0 deletions docs/.agent/specs/192-sender-and-channel.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 8 additions & 3 deletions docs/api-contracts/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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)*

Expand Down
17 changes: 12 additions & 5 deletions src/experience_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -46,7 +47,12 @@ 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)

@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
Expand All @@ -55,6 +61,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)
Expand All @@ -70,7 +77,7 @@ 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,
sender_id=settings.sender_id,
max_characters=settings.max_characters,
)
)
6 changes: 3 additions & 3 deletions src/experience_api/chat/adapters/dss/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ def __init__(
self,
*,
base_url: str,
channel: 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._channel = channel
self._sender_id = sender_id
self._max_characters = max_characters
self._clock = clock

Expand All @@ -51,7 +51,7 @@ async def stream_turn(
turn,
transaction_id=transaction_id,
now=self._clock(),
channel=self._channel,
sender_id=self._sender_id,
max_characters=self._max_characters,
)
# Leaving this block, including when the reader stops early, closes the
Expand Down
5 changes: 3 additions & 2 deletions src/experience_api/chat/adapters/dss/mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def to_dss_request(
*,
transaction_id: str,
now: datetime,
channel: str,
sender_id: str,
max_characters: int,
) -> dict[str, Any]:
"""The `/v1/turns` body for one turn, contract §7.
Expand All @@ -63,14 +63,15 @@ 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=[
_input(message)
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=(
Expand Down
1 change: 1 addition & 0 deletions src/experience_api/chat/adapters/dss/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class Context(_Request):
session_id: str
transaction_id: str
message_id: str
sender_id: str


class TextPart(_Request):
Expand Down
5 changes: 5 additions & 0 deletions src/experience_api/chat/adapters/http/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion src/experience_api/chat/adapters/http/mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from experience_api.chat.domain import (
Answer,
Block,
Channel,
ChatEvent,
ChatTurn,
Completed,
Expand All @@ -20,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,
Expand All @@ -34,6 +35,7 @@ def to_chat_turn(body: schemas.ChatRequest) -> ChatTurn:
if location
else None
),
channel=body.channel if body.channel is not None else default_channel,
)


Expand Down
10 changes: 7 additions & 3 deletions src/experience_api/chat/adapters/http/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
Loading
Loading